mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-10 16:08:20 -05: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:
21
backend/src/db/migrations/20251203224427_pam-aws-console.ts
Normal file
21
backend/src/db/migrations/20251203224427_pam-aws-console.ts
Normal file
@@ -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>;
|
||||
5
backend/src/ee/services/pam-resource/aws-iam/index.ts
Normal file
5
backend/src/ee/services/pam-resource/aws-iam/index.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,8 @@ export enum PamResourceType {
|
||||
CockroachDB = "cockroachdb",
|
||||
Elasticsearch = "elasticsearch",
|
||||
Snowflake = "snowflake",
|
||||
DynamoDB = "dynamodb"
|
||||
DynamoDB = "dynamodb",
|
||||
AwsIam = "aws-iam"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
@@ -28,7 +29,8 @@ export enum PamSessionStatus {
|
||||
Starting = "starting",
|
||||
Active = "active",
|
||||
Ended = "ended",
|
||||
Terminated = "terminated"
|
||||
Terminated = "terminated",
|
||||
Expired = "expired"
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
@@ -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" }
|
||||
};
|
||||
|
||||
@@ -120,6 +120,41 @@ export const useDeletePamAccount = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export type TAccessPamAccountDTO = {
|
||||
accountId: string;
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type TAccessPamAccountResponse = {
|
||||
sessionId: string;
|
||||
resourceType: string;
|
||||
consoleUrl?: string;
|
||||
metadata?: Record<string, string | undefined>;
|
||||
relayClientCertificate?: string;
|
||||
relayClientPrivateKey?: string;
|
||||
relayServerCertificateChain?: string;
|
||||
gatewayClientCertificate?: string;
|
||||
gatewayClientPrivateKey?: string;
|
||||
gatewayServerCertificateChain?: string;
|
||||
relayHost?: string;
|
||||
};
|
||||
|
||||
export const useAccessPamAccount = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ accountId, duration }: TAccessPamAccountDTO) => {
|
||||
const { data } = await apiRequest.post<TAccessPamAccountResponse>(
|
||||
"/api/v1/pam/accounts/access",
|
||||
{
|
||||
accountId,
|
||||
duration
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Folders
|
||||
export const useCreatePamFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
26
frontend/src/hooks/api/pam/types/aws-iam-resource.ts
Normal file
26
frontend/src/hooks/api/pam/types/aws-iam-resource.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
import { TBasePamAccount } from "./base-account";
|
||||
import { TBasePamResource } from "./base-resource";
|
||||
|
||||
export type TAwsIamConnectionDetails = {
|
||||
region: string;
|
||||
roleArn: string;
|
||||
};
|
||||
|
||||
export type TAwsIamCredentials = {
|
||||
targetRoleArn: string;
|
||||
maxSessionDuration: number;
|
||||
};
|
||||
|
||||
export type TAwsIamResource = Omit<TBasePamResource, "gatewayId"> & {
|
||||
resourceType: PamResourceType.AwsIam;
|
||||
gatewayId?: string | null;
|
||||
connectionDetails: TAwsIamConnectionDetails;
|
||||
};
|
||||
|
||||
export type TAwsIamAccount = Omit<
|
||||
TBasePamAccount,
|
||||
"rotationEnabled" | "rotationIntervalSeconds" | "lastRotatedAt"
|
||||
> & {
|
||||
credentials: TAwsIamCredentials;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { faCopy } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faExternalLink, faUpRightFromSquare, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import ms from "ms";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { PamResourceType, TPamAccount } from "@app/hooks/api/pam";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
account?: TPamAccount;
|
||||
@@ -14,7 +22,108 @@ type Props = {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
|
||||
const AwsIamAccessContent = ({
|
||||
account,
|
||||
onOpenChange
|
||||
}: {
|
||||
account: TPamAccount;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}) => {
|
||||
const [durationInput, setDurationInput] = useState("1h");
|
||||
const accessPamAccount = useAccessPamAccount();
|
||||
|
||||
const parsedDuration = useMemo(() => {
|
||||
try {
|
||||
const milliseconds = ms(durationInput);
|
||||
if (!milliseconds) return null;
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
// Min 15 minutes (900s), max 1 hour (3600s) due to AWS role chaining limitation
|
||||
if (seconds < 900 || seconds > 3600) return null;
|
||||
return seconds;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [durationInput]);
|
||||
|
||||
const handleAccessConsole = async () => {
|
||||
if (!parsedDuration) return;
|
||||
|
||||
try {
|
||||
const response = await accessPamAccount.mutateAsync({
|
||||
accountId: account.id,
|
||||
duration: `${parsedDuration}s`
|
||||
});
|
||||
|
||||
if (response.consoleUrl) {
|
||||
// Open the AWS Console URL in a new tab
|
||||
window.open(response.consoleUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
createNotification({
|
||||
text: "AWS Console opened in new tab",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
createNotification({
|
||||
text: "Failed to generate AWS Console URL",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to access AWS Console",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
label="Session Duration"
|
||||
helperText="Min 15m, max 1h (AWS role chaining limit). Examples: 30m, 1h"
|
||||
isError={durationInput.length > 0 && !parsedDuration}
|
||||
errorText="Invalid duration. Use format like 15m, 30m, 1h"
|
||||
>
|
||||
<Input
|
||||
value={durationInput}
|
||||
onChange={(e) => setDurationInput(e.target.value)}
|
||||
placeholder="1h"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="mb-4 rounded-sm border border-yellow-600/30 bg-yellow-600/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FontAwesomeIcon icon={faWarning} className="mt-0.5 text-yellow-500" />
|
||||
<div className="text-xs text-yellow-500">
|
||||
<strong>Important:</strong> AWS Console sessions cannot be terminated early. The session
|
||||
remains active until the STS token expires. All activity is logged in AWS CloudTrail.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAccessConsole}
|
||||
isLoading={accessPamAccount.isPending}
|
||||
isDisabled={!parsedDuration}
|
||||
colorSchema="secondary"
|
||||
className="w-full"
|
||||
leftIcon={<FontAwesomeIcon icon={faExternalLink} />}
|
||||
>
|
||||
Open AWS Console
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CliAccessContent = ({
|
||||
account,
|
||||
onOpenChange
|
||||
}: {
|
||||
account: TPamAccount;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}) => {
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : "";
|
||||
const siteURL = `${protocol}//${hostname}${portSuffix}`;
|
||||
@@ -76,56 +185,74 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
|
||||
}
|
||||
}, [account, cliDuration]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Duration"
|
||||
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
|
||||
/>
|
||||
<Input
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="permanent"
|
||||
isError={!isDurationValid}
|
||||
/>
|
||||
<FormLabel label="CLI Command" className="mt-4" />
|
||||
<div className="flex gap-2">
|
||||
<Input value={command} isDisabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(command);
|
||||
|
||||
createNotification({
|
||||
text: "Command copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<a
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>Install the Infisical CLI</span>
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
|
||||
if (!account) return null;
|
||||
|
||||
const isAwsIam = account.resource.resourceType === PamResourceType.AwsIam;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl pb-2"
|
||||
title="Access Account"
|
||||
subTitle={`Access ${account.name} using a CLI command.`}
|
||||
subTitle={
|
||||
isAwsIam
|
||||
? `Access ${account.name} via AWS Console.`
|
||||
: `Access ${account.name} using a CLI command.`
|
||||
}
|
||||
>
|
||||
<FormLabel
|
||||
label="Duration"
|
||||
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
|
||||
/>
|
||||
<Input
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="permanent"
|
||||
isError={!isDurationValid}
|
||||
/>
|
||||
<FormLabel label="CLI Command" className="mt-4" />
|
||||
<div className="flex gap-2">
|
||||
<Input value={command} isDisabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(command);
|
||||
|
||||
createNotification({
|
||||
text: "Command copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<a
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>Install the Infisical CLI</span>
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
{isAwsIam ? (
|
||||
<AwsIamAccessContent account={account} onOpenChange={onOpenChange} />
|
||||
) : (
|
||||
<CliAccessContent account={account} onOpenChange={onOpenChange} />
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { PamResourceType, TAwsIamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
import { GenericAccountFields } from "./GenericAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TAwsIamAccount;
|
||||
resourceId?: string;
|
||||
resourceType?: PamResourceType;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/;
|
||||
|
||||
const AwsIamCredentialsSchema = z.object({
|
||||
targetRoleArn: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Target Role ARN is required")
|
||||
.refine((val) => arnRoleRegex.test(val), {
|
||||
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
|
||||
}),
|
||||
// Max 1 hour (3600s) due to AWS role chaining limitation, min 15 min (900s)
|
||||
maxSessionDuration: z.coerce
|
||||
.number()
|
||||
.min(900, "Minimum session duration is 900 seconds (15 minutes)")
|
||||
.max(3600, "Maximum session duration is 3600 seconds (1 hour)")
|
||||
.default(3600)
|
||||
});
|
||||
|
||||
const genericAwsIamAccountFieldsSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(64, "Name must be at most 64 characters"),
|
||||
description: z.string().max(512).optional().nullable()
|
||||
});
|
||||
|
||||
const formSchema = genericAwsIamAccountFieldsSchema.extend({
|
||||
credentials: AwsIamCredentialsSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const AwsIamAccountForm = ({ account, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
const { projectId } = useProject();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: account ?? {
|
||||
name: "",
|
||||
description: "",
|
||||
credentials: {
|
||||
targetRoleArn: "",
|
||||
maxSessionDuration: 3600
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<GenericAccountFields />
|
||||
|
||||
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">AWS IAM Configuration</h4>
|
||||
|
||||
<Controller
|
||||
name="credentials.targetRoleArn"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-3"
|
||||
helperText="The ARN of the IAM role that users will assume to access the AWS Console"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Target Role ARN"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="arn:aws:iam::123456789012:role/infisical-pam-MyTargetRole"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="credentials.maxSessionDuration"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
helperText="In seconds. Min 900 (15m), max 3600 (1h) due to AWS role chaining limit."
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Session Duration (seconds)"
|
||||
>
|
||||
<Input {...field} type="number" placeholder="3600" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="mb-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="target-role-setup">
|
||||
<AccordionTrigger>Target Role Setup</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
The target role must have a trust policy that allows the Infisical PAM role to
|
||||
assume it. If you used the{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">infisical-pam-*</code>{" "}
|
||||
naming convention, no additional changes are needed to the PAM role.
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Target role trust policy:
|
||||
</p>
|
||||
<pre className="mb-3 max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PAM_ROLE_NAME>"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "${projectId}"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`}
|
||||
</pre>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
<strong>Note:</strong> Replace{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1"><YOUR_ACCOUNT_ID></code> with
|
||||
your AWS account ID and{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1"><YOUR_PAM_ROLE_NAME></code>{" "}
|
||||
with the name of the PAM role you created (e.g.,{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1">InfisicalPAMRole</code>). The
|
||||
External ID <code className="rounded bg-mineshaft-700 px-1">{projectId}</code> is
|
||||
your current project ID. If your target role name doesn't follow the{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1">infisical-pam-*</code> pattern, you
|
||||
must update the PAM role's permissions policy to include the target role ARN.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="rounded-sm border border-yellow-600/30 bg-yellow-600/10 p-3">
|
||||
<p className="text-xs text-yellow-500">
|
||||
<strong>Note:</strong> While users cannot terminate AWS Console sessions directly,
|
||||
administrators can revoke active sessions by using the{" "}
|
||||
<a
|
||||
href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-yellow-400"
|
||||
>
|
||||
Revoke Sessions
|
||||
</a>{" "}
|
||||
feature in the IAM console. All activity is logged in AWS CloudTrail.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Account" : "Create Account"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { PamAccountHeader } from "../PamAccountHeader";
|
||||
import { AwsIamAccountForm } from "./AwsIamAccountForm";
|
||||
import { MySQLAccountForm } from "./MySQLAccountForm";
|
||||
import { PostgresAccountForm } from "./PostgresAccountForm";
|
||||
import { SshAccountForm } from "./SshAccountForm";
|
||||
@@ -70,6 +71,10 @@ const CreateForm = ({
|
||||
return (
|
||||
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
case PamResourceType.AwsIam:
|
||||
return (
|
||||
<AwsIamAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -100,6 +105,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
case PamResourceType.AwsIam:
|
||||
return <AwsIamAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
search: string;
|
||||
isFlatView: boolean;
|
||||
accountPath?: string;
|
||||
isAccessLoading?: boolean;
|
||||
};
|
||||
|
||||
export const PamAccountRow = ({
|
||||
@@ -50,7 +51,8 @@ export const PamAccountRow = ({
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isFlatView,
|
||||
accountPath
|
||||
accountPath,
|
||||
isAccessLoading
|
||||
}: Props) => {
|
||||
const { id, name } = account;
|
||||
|
||||
@@ -127,6 +129,8 @@ export const PamAccountRow = ({
|
||||
leftIcon={<FontAwesomeIcon icon={faRightToBracket} />}
|
||||
onClick={() => onAccess(account)}
|
||||
size="xs"
|
||||
isLoading={isAccessLoading}
|
||||
isDisabled={isAccessLoading}
|
||||
>
|
||||
Access
|
||||
</Button>
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
PAM_RESOURCE_TYPE_MAP,
|
||||
PamAccountOrderBy,
|
||||
PamAccountView,
|
||||
PamResourceType,
|
||||
TPamAccount,
|
||||
TPamFolder
|
||||
} from "@app/hooks/api/pam";
|
||||
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
|
||||
@@ -67,6 +69,7 @@ import { PamDeleteFolderModal } from "./PamDeleteFolderModal";
|
||||
import { PamFolderRow } from "./PamFolderRow";
|
||||
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
|
||||
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
|
||||
import { useAccessAwsIamAccount } from "./useAccessAwsIamAccount";
|
||||
|
||||
type PamAccountFilter = {
|
||||
resourceIds: string[];
|
||||
@@ -78,6 +81,7 @@ type Props = {
|
||||
|
||||
export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
|
||||
const { accessAwsIam, loadingAccountId } = useAccessAwsIamAccount();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"misc",
|
||||
@@ -419,8 +423,14 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
search={search}
|
||||
isFlatView={accountView === PamAccountView.Flat}
|
||||
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
|
||||
onAccess={(e) => {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
isAccessLoading={loadingAccountId === account.id}
|
||||
onAccess={(e: TPamAccount) => {
|
||||
// For AWS IAM, directly open console without modal
|
||||
if (e.resource.resourceType === PamResourceType.AwsIam) {
|
||||
accessAwsIam(e);
|
||||
} else {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
}
|
||||
}}
|
||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||
|
||||
@@ -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 (
|
||||
<PamAccountForm
|
||||
onComplete={onComplete}
|
||||
onBack={() => setSelectedResource(null)}
|
||||
resourceId={selectedResource.id}
|
||||
resourceName={selectedResource.name}
|
||||
resourceType={selectedResource.resourceType}
|
||||
projectId={projectId}
|
||||
folderId={currentFolderId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ResourceSelect projectId={projectId} onSubmit={(e) => 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 (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<Modal isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Add Account"
|
||||
subTitle="Select a resource to add an account under."
|
||||
bodyClassName="overflow-visible"
|
||||
bodyClassName={selectedResource ? undefined : "overflow-visible"}
|
||||
>
|
||||
<Content
|
||||
projectId={projectId}
|
||||
onComplete={(account) => {
|
||||
if (onComplete) onComplete(account);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
{selectedResource ? (
|
||||
<PamAccountForm
|
||||
onComplete={(account) => {
|
||||
if (onComplete) onComplete(account);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onBack={() => setSelectedResource(null)}
|
||||
resourceId={selectedResource.id}
|
||||
resourceName={selectedResource.name}
|
||||
resourceType={selectedResource.resourceType}
|
||||
projectId={projectId}
|
||||
folderId={currentFolderId ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam";
|
||||
import { TAwsIamCredentials } from "@app/hooks/api/pam/types";
|
||||
|
||||
export const useAccessAwsIamAccount = () => {
|
||||
const accessPamAccount = useAccessPamAccount();
|
||||
const [loadingAccountId, setLoadingAccountId] = useState<string | null>(null);
|
||||
|
||||
const accessAwsIam = async (account: TPamAccount) => {
|
||||
if (account.resource.resourceType !== PamResourceType.AwsIam) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setLoadingAccountId(account.id);
|
||||
|
||||
try {
|
||||
const response = await accessPamAccount.mutateAsync({
|
||||
accountId: account.id,
|
||||
duration: `${(account.credentials as TAwsIamCredentials).maxSessionDuration}s`
|
||||
});
|
||||
|
||||
if (response.consoleUrl) {
|
||||
// Open the AWS Console URL in a new tab
|
||||
window.open(response.consoleUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
createNotification({
|
||||
text: "AWS Console opened in new tab",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Failed to generate AWS Console URL",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setLoadingAccountId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
accessAwsIam,
|
||||
isPending: accessPamAccount.isPending,
|
||||
loadingAccountId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { PamResourceType, TAwsIamResource } from "@app/hooks/api/pam";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
resource?: TAwsIamResource;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/;
|
||||
|
||||
const AwsIamConnectionDetailsSchema = z.object({
|
||||
region: z.string().trim().min(1, "Region is required"),
|
||||
roleArn: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "PAM Role ARN is required")
|
||||
.refine((val) => arnRoleRegex.test(val), {
|
||||
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 64, field: "Name" }),
|
||||
resourceType: z.literal(PamResourceType.AwsIam),
|
||||
connectionDetails: AwsIamConnectionDetailsSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
// Infisical AWS account IDs for trust policy
|
||||
const INFISICAL_AWS_ACCOUNT_US = "381492033652";
|
||||
const INFISICAL_AWS_ACCOUNT_EU = "345594589636";
|
||||
|
||||
export const AwsIamResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(resource);
|
||||
const { projectId } = useProject();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.AwsIam,
|
||||
connectionDetails: {
|
||||
region: "",
|
||||
roleArn: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="Name must be slug-friendly"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Name"
|
||||
>
|
||||
<Input autoFocus placeholder="my-aws-console" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="connectionDetails.region"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="AWS Region"
|
||||
tooltipText="This region is used for the STS endpoint and initial console URL. It does not restrict access to resources in other regions. To restrict region access, configure region conditions in the target role's IAM policy."
|
||||
>
|
||||
<Input placeholder="us-east-1" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="connectionDetails.roleArn"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="The ARN of the Infisical PAM role that can assume target roles"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="PAM Role ARN"
|
||||
>
|
||||
<Input placeholder="arn:aws:iam::123456789012:role/InfisicalPAMRole" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Accordion type="single" collapsible className="mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="aws-iam-role-setup">
|
||||
<AccordionTrigger>AWS IAM Role Setup</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
Before creating this resource, you need to set up an IAM role in your AWS account
|
||||
that Infisical can assume. Follow these steps:
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Step 1: Create a permissions policy for assuming target roles
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
This policy allows the PAM role to assume target roles. We recommend using the{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">infisical-pam-*</code>{" "}
|
||||
naming convention for target roles.
|
||||
</p>
|
||||
<pre className="mb-4 max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/infisical-pam-*"
|
||||
}]
|
||||
}`}
|
||||
</pre>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Step 2: Create the PAM role with a trust policy
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
Create an IAM role (e.g.,{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">InfisicalPAMRole</code>)
|
||||
with the permissions policy above and the following trust policy:
|
||||
</p>
|
||||
<pre className="mb-4 max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::${INFISICAL_AWS_ACCOUNT_US}:root"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "${projectId}"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`}
|
||||
</pre>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
<strong>Note:</strong> Use{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1">{INFISICAL_AWS_ACCOUNT_US}</code>{" "}
|
||||
for US region or{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1">{INFISICAL_AWS_ACCOUNT_EU}</code>{" "}
|
||||
for EU region. The External ID{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1">{projectId}</code> is your current
|
||||
project ID.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Details" : "Create Resource"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -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 <MySQLResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SSHResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.AwsIam:
|
||||
return <AwsIamResourceForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
|
||||
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.AwsIam:
|
||||
return <AwsIamResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
|
||||
}
|
||||
|
||||
@@ -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 && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
|
||||
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
|
||||
{!hasLogs && (
|
||||
{isAwsIamSession && (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
<div className="text-center">
|
||||
<div className="mb-2">AWS Console session activity is logged in AWS CloudTrail</div>
|
||||
<div className="text-xs text-bunker-400">
|
||||
View detailed activity logs for this session in your AWS CloudTrail console.
|
||||
<br />
|
||||
<a
|
||||
href="https://console.aws.amazon.com/cloudtrail"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
Open AWS CloudTrail
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasLogs && !isAwsIamSession && (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
<div className="text-center">
|
||||
<div className="mb-2">Session logs are not yet available</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ActivityIcon,
|
||||
BanIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
ClockIcon,
|
||||
GavelIcon,
|
||||
LucideIcon
|
||||
} from "lucide-react";
|
||||
@@ -33,6 +34,10 @@ const PAM_SESSION_STATUS_CONFIG: Record<PamSessionStatus, StatusConfig> = {
|
||||
[PamSessionStatus.Ended]: {
|
||||
variant: "neutral",
|
||||
icon: BanIcon
|
||||
},
|
||||
[PamSessionStatus.Expired]: {
|
||||
variant: "warning",
|
||||
icon: ClockIcon
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user