Merge branch 'main' into PLATFRM-114

This commit is contained in:
x032205
2025-12-08 12:56:20 -05:00
57 changed files with 1645 additions and 174 deletions
@@ -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();
});
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const PamResourcesSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
projectId: z.string(), projectId: z.string(),
name: z.string(), name: z.string(),
gatewayId: z.string().uuid(), gatewayId: z.string().uuid().nullable().optional(),
resourceType: z.string(), resourceType: z.string(),
encryptedConnectionDetails: zodBuffer, encryptedConnectionDetails: zodBuffer,
createdAt: z.date(), createdAt: z.date(),
@@ -1,3 +1,8 @@
import {
CreateAwsIamAccountSchema,
SanitizedAwsIamAccountWithResourceSchema,
UpdateAwsIamAccountSchema
} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
import { import {
CreateMySQLAccountSchema, CreateMySQLAccountSchema,
SanitizedMySQLAccountWithResourceSchema, SanitizedMySQLAccountWithResourceSchema,
@@ -44,5 +49,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fasti
createAccountSchema: CreateSSHAccountSchema, createAccountSchema: CreateSSHAccountSchema,
updateAccountSchema: UpdateSSHAccountSchema updateAccountSchema: UpdateSSHAccountSchema
}); });
},
[PamResource.AwsIam]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.AwsIam,
accountResponseSchema: SanitizedAwsIamAccountWithResourceSchema,
createAccountSchema: CreateAwsIamAccountSchema,
updateAccountSchema: UpdateAwsIamAccountSchema
});
} }
}; };
@@ -22,7 +22,7 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
folderId?: C["folderId"]; folderId?: C["folderId"];
name: C["name"]; name: C["name"];
description?: C["description"]; description?: C["description"];
rotationEnabled: C["rotationEnabled"]; rotationEnabled?: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"];
}>; }>;
updateAccountSchema: z.ZodType<{ updateAccountSchema: z.ZodType<{
@@ -65,7 +65,7 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
folderId: req.body.folderId, folderId: req.body.folderId,
name: req.body.name, name: req.body.name,
description: req.body.description, description: req.body.description,
rotationEnabled: req.body.rotationEnabled, rotationEnabled: req.body.rotationEnabled ?? false,
rotationIntervalSeconds: req.body.rotationIntervalSeconds rotationIntervalSeconds: req.body.rotationIntervalSeconds
} }
} }
@@ -3,8 +3,10 @@ import { z } from "zod";
import { PamFoldersSchema } from "@app/db/schemas"; import { PamFoldersSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums"; 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 { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas";
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
@@ -18,9 +20,12 @@ import { AuthMode } from "@app/services/auth/auth-type";
const SanitizedAccountSchema = z.union([ const SanitizedAccountSchema = z.union([
SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS
SanitizedPostgresAccountWithResourceSchema, SanitizedPostgresAccountWithResourceSchema,
SanitizedMySQLAccountWithResourceSchema SanitizedMySQLAccountWithResourceSchema,
SanitizedAwsIamAccountWithResourceSchema
]); ]);
type TSanitizedAccount = z.infer<typeof SanitizedAccountSchema>;
export const registerPamAccountRouter = async (server: FastifyZodProvider) => { export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "GET", method: "GET",
@@ -93,7 +98,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
} }
}); });
return { accounts, folders, totalCount, folderId, folderPaths }; return { accounts: accounts as TSanitizedAccount[], folders, totalCount, folderId, folderPaths };
} }
}); });
@@ -125,18 +130,19 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
}) })
}), }),
response: { response: {
200: z.object({ 200: z.discriminatedUnion("resourceType", [
sessionId: z.string(), // Gateway-based resources (Postgres, MySQL, SSH)
resourceType: z.nativeEnum(PamResource), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Postgres) }),
relayClientCertificate: z.string(), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.MySQL) }),
relayClientPrivateKey: z.string(), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }),
relayServerCertificateChain: z.string(), // AWS IAM (no gateway, returns console URL)
gatewayClientCertificate: z.string(), z.object({
gatewayClientPrivateKey: z.string(), sessionId: z.string(),
gatewayServerCertificateChain: z.string(), resourceType: z.literal(PamResource.AwsIam),
relayHost: z.string(), consoleUrl: z.string().url(),
metadata: z.record(z.string(), z.string().optional()).optional() metadata: z.record(z.string(), z.string().optional()).optional()
}) })
])
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
@@ -162,7 +168,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
orgId: req.permission.orgId, orgId: req.permission.orgId,
projectId: response.projectId, projectId: req.body.projectId,
event: { event: {
type: EventType.PAM_ACCOUNT_ACCESS, type: EventType.PAM_ACCOUNT_ACCESS,
metadata: { metadata: {
@@ -1,3 +1,8 @@
import {
CreateAwsIamResourceSchema,
SanitizedAwsIamResourceSchema,
UpdateAwsIamResourceSchema
} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
import { import {
CreateMySQLResourceSchema, CreateMySQLResourceSchema,
MySQLResourceSchema, MySQLResourceSchema,
@@ -44,5 +49,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
createResourceSchema: CreateSSHResourceSchema, createResourceSchema: CreateSSHResourceSchema,
updateResourceSchema: UpdateSSHResourceSchema 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<{ createResourceSchema: z.ZodType<{
projectId: T["projectId"]; projectId: T["projectId"];
connectionDetails: T["connectionDetails"]; connectionDetails: T["connectionDetails"];
gatewayId: T["gatewayId"]; gatewayId?: T["gatewayId"];
name: T["name"]; name: T["name"];
rotationAccountCredentials?: T["rotationAccountCredentials"]; rotationAccountCredentials?: T["rotationAccountCredentials"];
}>; }>;
@@ -103,7 +103,7 @@ export const registerPamResourceEndpoints = <T extends TPamResource>({
type: EventType.PAM_RESOURCE_CREATE, type: EventType.PAM_RESOURCE_CREATE,
metadata: { metadata: {
resourceType, resourceType,
gatewayId: req.body.gatewayId, ...(req.body.gatewayId && { gatewayId: req.body.gatewayId }),
name: req.body.name name: req.body.name
} }
} }
@@ -150,8 +150,8 @@ export const registerPamResourceEndpoints = <T extends TPamResource>({
metadata: { metadata: {
resourceId: req.params.resourceId, resourceId: req.params.resourceId,
resourceType, resourceType,
gatewayId: req.body.gatewayId, ...(req.body.gatewayId && { gatewayId: req.body.gatewayId }),
name: req.body.name ...(req.body.name && { name: req.body.name })
} }
} }
}); });
@@ -1,6 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; 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 { import {
MySQLResourceListItemSchema, MySQLResourceListItemSchema,
SanitizedMySQLResourceSchema SanitizedMySQLResourceSchema
@@ -22,13 +26,15 @@ import { AuthMode } from "@app/services/auth/auth-type";
const SanitizedResourceSchema = z.union([ const SanitizedResourceSchema = z.union([
SanitizedPostgresResourceSchema, SanitizedPostgresResourceSchema,
SanitizedMySQLResourceSchema, SanitizedMySQLResourceSchema,
SanitizedSSHResourceSchema SanitizedSSHResourceSchema,
SanitizedAwsIamResourceSchema
]); ]);
const ResourceOptionsSchema = z.discriminatedUnion("resource", [ const ResourceOptionsSchema = z.discriminatedUnion("resource", [
PostgresResourceListItemSchema, PostgresResourceListItemSchema,
MySQLResourceListItemSchema, MySQLResourceListItemSchema,
SSHResourceListItemSchema SSHResourceListItemSchema,
AwsIamResourceListItemSchema
]); ]);
export const registerPamResourceRouter = async (server: FastifyZodProvider) => { export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
@@ -84,7 +84,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
privilege: { privilege: {
...privilege, ...privilege,
identityId: req.body.identityId, identityId: req.body.identityId,
projectMembershipId: req.body.projectId,
projectId: req.body.projectId, projectId: req.body.projectId,
slug: privilege.name slug: privilege.name
} }
@@ -168,7 +167,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
privilege: { privilege: {
...privilege, ...privilege,
identityId: privilegeDoc.actorIdentityId as string, identityId: privilegeDoc.actorIdentityId as string,
projectMembershipId: privilegeDoc.projectId as string,
projectId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string,
slug: privilege.name slug: privilege.name
} }
@@ -222,7 +220,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
privilege: { privilege: {
...privilege, ...privilege,
identityId: privilegeDoc.actorIdentityId as string, identityId: privilegeDoc.actorIdentityId as string,
projectMembershipId: privilegeDoc.projectId as string,
projectId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string,
slug: privilege.name slug: privilege.name
} }
@@ -276,7 +273,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
privilege: { privilege: {
...privilege, ...privilege,
identityId: privilegeDoc.actorIdentityId as string, identityId: privilegeDoc.actorIdentityId as string,
projectMembershipId: privilegeDoc.projectId as string,
projectId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string,
slug: privilege.name slug: privilege.name
} }
@@ -339,7 +335,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
privilege: { privilege: {
...privilege, ...privilege,
identityId: req.query.identityId, identityId: req.query.identityId,
projectMembershipId: privilege.projectId as string,
projectId, projectId,
slug: privilege.name slug: privilege.name
} }
@@ -391,7 +386,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
privileges: privileges.map((privilege) => ({ privileges: privileges.map((privilege) => ({
...privilege, ...privilege,
identityId: req.query.identityId, identityId: req.query.identityId,
projectMembershipId: privilege.projectId as string,
projectId: req.query.projectId, projectId: req.query.projectId,
slug: privilege.name slug: privilege.name
})) }))
@@ -4173,7 +4173,7 @@ interface PamResourceCreateEvent {
type: EventType.PAM_RESOURCE_CREATE; type: EventType.PAM_RESOURCE_CREATE;
metadata: { metadata: {
resourceType: string; resourceType: string;
gatewayId: string; gatewayId?: string;
name: string; name: string;
}; };
} }
@@ -72,17 +72,24 @@ export const decryptAccount = async <
account: T, account: T,
projectId: string, projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey"> kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<T & { credentials: TPamAccountCredentials; lastRotationMessage: string | null }> => { ): Promise<
Omit<T, "encryptedCredentials" | "encryptedLastRotationMessage"> & {
credentials: TPamAccountCredentials;
lastRotationMessage: string | null;
}
> => {
const { encryptedCredentials, encryptedLastRotationMessage, ...rest } = account;
return { return {
...account, ...rest,
credentials: await decryptAccountCredentials({ credentials: await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials, encryptedCredentials,
projectId, projectId,
kmsService kmsService
}), }),
lastRotationMessage: account.encryptedLastRotationMessage lastRotationMessage: encryptedLastRotationMessage
? await decryptAccountMessage({ ? await decryptAccountMessage({
encryptedMessage: account.encryptedLastRotationMessage, encryptedMessage: encryptedLastRotationMessage,
projectId, projectId,
kmsService kmsService
}) })
@@ -3,6 +3,11 @@ import path from "node:path";
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas"; 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 { 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 { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
@@ -30,6 +35,7 @@ import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/appro
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; 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 { TProjectDALFactory } from "@app/services/project/project-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
@@ -41,7 +47,8 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
import { PamResource } from "../pam-resource/pam-resource-enums"; import { PamResource } from "../pam-resource/pam-resource-enums";
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; 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 { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
import { PamSessionStatus } from "../pam-session/pam-session-enums"; import { PamSessionStatus } from "../pam-session/pam-session-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
@@ -67,6 +74,7 @@ type TPamAccountServiceFactoryDep = {
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
approvalPolicyDAL: TApprovalPolicyDALFactory; approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory; approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory;
pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">;
}; };
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>; export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
@@ -85,7 +93,8 @@ export const pamAccountServiceFactory = ({
gatewayV2Service, gatewayV2Service,
auditLogService, auditLogService,
approvalPolicyDAL, approvalPolicyDAL,
approvalRequestGrantsDAL approvalRequestGrantsDAL,
pamSessionExpirationService
}: TPamAccountServiceFactoryDep) => { }: TPamAccountServiceFactoryDep) => {
const create = async ( const create = async (
{ {
@@ -153,7 +162,8 @@ export const pamAccountServiceFactory = ({
resource.resourceType as PamResource, resource.resourceType as PamResource,
connectionDetails, connectionDetails,
resource.gatewayId, resource.gatewayId,
gatewayV2Service gatewayV2Service,
resource.projectId
); );
const validatedCredentials = await factory.validateAccountCredentials(credentials); const validatedCredentials = await factory.validateAccountCredentials(credentials);
@@ -268,7 +278,8 @@ export const pamAccountServiceFactory = ({
resource.resourceType as PamResource, resource.resourceType as PamResource,
connectionDetails, connectionDetails,
resource.gatewayId, resource.gatewayId,
gatewayV2Service gatewayV2Service,
account.projectId
); );
const decryptedCredentials = await decryptAccountCredentials({ const decryptedCredentials = await decryptAccountCredentials({
@@ -297,17 +308,27 @@ export const pamAccountServiceFactory = ({
return decryptAccount(account, account.projectId, kmsService); return decryptAccount(account, account.projectId, kmsService);
} }
const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); try {
const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc);
return { return {
...(await decryptAccount(updatedAccount, account.projectId, kmsService)), ...(await decryptAccount(updatedAccount, account.projectId, kmsService)),
resource: { resource: {
id: resource.id, id: resource.id,
name: resource.name, name: resource.name,
resourceType: resource.resourceType, resourceType: resource.resourceType,
rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials
}
};
} catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
throw new BadRequestError({
message: `Account with name '${name}' already exists for this path`
});
} }
};
throw err;
}
}; };
const deleteById = async (id: string, actor: OrgServiceActor) => { const deleteById = async (id: string, actor: OrgServiceActor) => {
@@ -446,7 +467,7 @@ export const pamAccountServiceFactory = ({
const totalCount = totalFolderCount + totalAccountCount; const totalCount = totalFolderCount + totalAccountCount;
const decryptedAndPermittedAccounts: Array< const decryptedAndPermittedAccounts: Array<
TPamAccounts & { Omit<TPamAccounts, "encryptedCredentials" | "encryptedLastRotationMessage"> & {
resource: Pick<TPamResources, "id" | "name" | "resourceType"> & { rotationCredentialsConfigured: boolean }; resource: Pick<TPamResources, "id" | "name" | "resourceType"> & { rotationCredentialsConfigured: boolean };
credentials: TPamAccountCredentials; credentials: TPamAccountCredentials;
lastRotationMessage: string | null; lastRotationMessage: string | null;
@@ -594,6 +615,64 @@ export const pamAccountServiceFactory = ({
); );
} }
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
resource,
account.projectId,
kmsService
);
const user = await userDAL.findById(actor.id);
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
if (resourceType === PamResource.AwsIam) {
const awsCredentials = (await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId: account.projectId
})) as TAwsIamAccountCredentials;
const { consoleUrl, expiresAt } = await generateConsoleFederationUrl({
connectionDetails,
targetRoleArn: awsCredentials.targetRoleArn,
roleSessionName: actorEmail,
projectId: account.projectId, // Use project ID as External ID for security
sessionDuration: awsCredentials.defaultSessionDuration
});
const session = await pamSessionDAL.create({
accountName: account.name,
actorEmail,
actorIp,
actorName,
actorUserAgent,
projectId: account.projectId,
resourceName: resource.name,
resourceType: resource.resourceType,
status: PamSessionStatus.Active, // AWS IAM sessions are immediately active
accountId: account.id,
userId: actor.id,
expiresAt,
startedAt: new Date()
});
// Schedule session expiration job to run at expiresAt
await pamSessionExpirationService.scheduleSessionExpiration(session.id, expiresAt);
return {
sessionId: session.id,
resourceType,
account,
consoleUrl,
metadata: {
awsAccountId: extractAwsAccountIdFromArn(connectionDetails.roleArn),
targetRoleArn: awsCredentials.targetRoleArn,
federatedUsername: actorEmail,
expiresAt: expiresAt.toISOString()
}
};
}
// For gateway-based resources (Postgres, MySQL, SSH), create session first
const session = await pamSessionDAL.create({ const session = await pamSessionDAL.create({
accountName: account.name, accountName: account.name,
actorEmail, actorEmail,
@@ -609,18 +688,17 @@ export const pamAccountServiceFactory = ({
expiresAt: new Date(Date.now() + duration) expiresAt: new Date(Date.now() + duration)
}); });
const { connectionDetails, gatewayId, resourceType } = await decryptResource(resource, projectId, kmsService); if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required for this resource type" });
const user = await userDAL.findById(actor.id); }
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({
gatewayId, gatewayId,
duration, duration,
sessionId: session.id, sessionId: session.id,
resourceType: resource.resourceType as PamResource, resourceType: resource.resourceType as PamResource,
host: connectionDetails.host, host: (connectionDetails as TSqlResourceConnectionDetails).host,
port: connectionDetails.port, port: (connectionDetails as TSqlResourceConnectionDetails).port,
actorMetadata: { actorMetadata: {
id: actor.id, id: actor.id,
type: actor.type, type: actor.type,
@@ -644,11 +722,11 @@ export const pamAccountServiceFactory = ({
projectId projectId
})) as TSqlResourceConnectionDetails; })) as TSqlResourceConnectionDetails;
const credentials = await decryptAccountCredentials({ const credentials = (await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials, encryptedCredentials: account.encryptedCredentials,
kmsService, kmsService,
projectId projectId
}); })) as TSqlAccountCredentials;
metadata = { metadata = {
username: credentials.username, username: credentials.username,
@@ -660,11 +738,11 @@ export const pamAccountServiceFactory = ({
break; break;
case PamResource.SSH: case PamResource.SSH:
{ {
const credentials = await decryptAccountCredentials({ const credentials = (await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials, encryptedCredentials: account.encryptedCredentials,
kmsService, kmsService,
projectId projectId
}); })) as TSSHAccountCredentials;
metadata = { metadata = {
username: credentials.username username: credentials.username
@@ -737,7 +815,7 @@ export const pamAccountServiceFactory = ({
const resource = await pamResourceDAL.findById(account.resourceId); const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); 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({ throw new ForbiddenRequestError({
message: "Identity does not have access to fetch the PAM session credentials" message: "Identity does not have access to fetch the PAM session credentials"
}); });
@@ -801,7 +879,8 @@ export const pamAccountServiceFactory = ({
resourceType as PamResource, resourceType as PamResource,
connectionDetails, connectionDetails,
gatewayId, gatewayId,
gatewayV2Service gatewayV2Service,
account.projectId
); );
const newCredentials = await factory.rotateAccountCredentials( const newCredentials = await factory.rotateAccountCredentials(
@@ -6,8 +6,10 @@ import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
// DTOs // DTOs
export type TCreateAccountDTO = Pick< export type TCreateAccountDTO = Pick<
TPamAccount, TPamAccount,
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds"
>; > & {
rotationEnabled?: boolean;
};
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & { export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
accountId: string; accountId: string;
@@ -0,0 +1,245 @@
import { AssumeRoleCommand, Credentials, STSClient, STSClientConfig } from "@aws-sdk/client-sts";
import { CustomAWSHasher } from "@app/lib/aws/hashing";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types";
const AWS_STS_MIN_DURATION_SECONDS = 900;
// We hardcode us-east-1 because:
// 1. IAM is global - roles can be assumed from any STS regional endpoint
// 2. The temporary credentials returned work globally across all AWS regions
// 3. The target account's resources can be in any region - it doesn't affect STS calls
const AWS_STS_DEFAULT_REGION = "us-east-1";
const createStsClient = (credentials?: Credentials): STSClient => {
const appCfg = getConfig();
const config: STSClientConfig = {
region: AWS_STS_DEFAULT_REGION,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher
};
if (credentials) {
// Use provided credentials (for role chaining)
config.credentials = {
accessKeyId: credentials.AccessKeyId!,
secretAccessKey: credentials.SecretAccessKey!,
sessionToken: credentials.SessionToken
};
} else if (appCfg.PAM_AWS_ACCESS_KEY_ID && appCfg.PAM_AWS_SECRET_ACCESS_KEY) {
// Use configured static credentials
config.credentials = {
accessKeyId: appCfg.PAM_AWS_ACCESS_KEY_ID,
secretAccessKey: appCfg.PAM_AWS_SECRET_ACCESS_KEY
};
}
// Otherwise uses instance profile if hosting on AWS
return new STSClient(config);
};
/**
* Assumes the PAM role and returns the credentials.
* Returns null if assumption fails (for validation) or throws if throwOnError is true.
*/
const assumePamRole = async ({
connectionDetails,
projectId,
sessionDuration = AWS_STS_MIN_DURATION_SECONDS,
sessionNameSuffix = "validation",
throwOnError = false
}: {
connectionDetails: TAwsIamResourceConnectionDetails;
projectId: string;
sessionDuration?: number;
sessionNameSuffix?: string;
throwOnError?: boolean;
}): Promise<Credentials | null> => {
const stsClient = createStsClient();
try {
const result = await stsClient.send(
new AssumeRoleCommand({
RoleArn: connectionDetails.roleArn,
RoleSessionName: `infisical-pam-${sessionNameSuffix}-${Date.now()}`,
DurationSeconds: sessionDuration,
ExternalId: projectId
})
);
if (!result.Credentials) {
if (throwOnError) {
throw new InternalServerError({
message: "Failed to assume PAM role - AWS STS did not return credentials"
});
}
return null;
}
return result.Credentials;
} catch (error) {
if (throwOnError) {
throw new InternalServerError({
message: `Failed to assume PAM role - AWS STS did not return credentials: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
return null;
}
};
/**
* Assumes a target role using PAM role credentials (role chaining).
* Returns null if assumption fails (for validation) or throws if throwOnError is true.
*/
const assumeTargetRole = async ({
pamCredentials,
targetRoleArn,
projectId,
roleSessionName,
sessionDuration = AWS_STS_MIN_DURATION_SECONDS,
throwOnError = false
}: {
pamCredentials: Credentials;
targetRoleArn: string;
projectId: string;
roleSessionName: string;
sessionDuration?: number;
throwOnError?: boolean;
}): Promise<Credentials | null> => {
const chainedStsClient = createStsClient(pamCredentials);
try {
const result = await chainedStsClient.send(
new AssumeRoleCommand({
RoleArn: targetRoleArn,
RoleSessionName: roleSessionName,
DurationSeconds: sessionDuration,
ExternalId: projectId
})
);
if (!result.Credentials) {
if (throwOnError) {
throw new BadRequestError({
message: "Failed to assume target role - verify the target role trust policy allows the PAM role to assume it"
});
}
return null;
}
return result.Credentials;
} catch (error) {
if (throwOnError) {
throw new InternalServerError({
message: `Failed to assume target role - AWS STS did not return credentials: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
return null;
}
};
export const validatePamRoleConnection = async (
connectionDetails: TAwsIamResourceConnectionDetails,
projectId: string
): Promise<boolean> => {
try {
const credentials = await assumePamRole({ connectionDetails, projectId });
return credentials !== null;
} catch {
return false;
}
};
export const validateTargetRoleAssumption = async ({
connectionDetails,
targetRoleArn,
projectId
}: {
connectionDetails: TAwsIamResourceConnectionDetails;
targetRoleArn: string;
projectId: string;
}): Promise<boolean> => {
try {
const pamCredentials = await assumePamRole({ connectionDetails, projectId });
if (!pamCredentials) return false;
const targetCredentials = await assumeTargetRole({
pamCredentials,
targetRoleArn,
projectId,
roleSessionName: `infisical-pam-target-validation-${Date.now()}`
});
return targetCredentials !== null;
} catch {
return false;
}
};
/**
* Assumes the target role and generates a federated console sign-in URL.
*/
export const generateConsoleFederationUrl = async ({
connectionDetails,
targetRoleArn,
roleSessionName,
projectId,
sessionDuration
}: {
connectionDetails: TAwsIamResourceConnectionDetails;
targetRoleArn: string;
roleSessionName: string;
projectId: string;
sessionDuration: number;
}): Promise<{ consoleUrl: string; expiresAt: Date }> => {
const pamCredentials = await assumePamRole({
connectionDetails,
projectId,
sessionDuration,
sessionNameSuffix: "session",
throwOnError: true
});
const targetCredentials = await assumeTargetRole({
pamCredentials: pamCredentials!,
targetRoleArn,
projectId,
roleSessionName,
sessionDuration,
throwOnError: true
});
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetCredentials!;
// Generate federation URL
const sessionJson = JSON.stringify({
sessionId: AccessKeyId,
sessionKey: SecretAccessKey,
sessionToken: SessionToken
});
const federationEndpoint = "https://signin.aws.amazon.com/federation";
const signinTokenUrl = `${federationEndpoint}?Action=getSigninToken&Session=${encodeURIComponent(sessionJson)}`;
const tokenResponse = await request.get<{ SigninToken?: string }>(signinTokenUrl);
if (!tokenResponse.data.SigninToken) {
throw new InternalServerError({
message: `AWS federation endpoint did not return a SigninToken: ${JSON.stringify(tokenResponse.data).substring(0, 200)}`
});
}
const consoleDestination = `https://console.aws.amazon.com/`;
const consoleUrl = `${federationEndpoint}?Action=login&SigninToken=${encodeURIComponent(tokenResponse.data.SigninToken)}&Destination=${encodeURIComponent(consoleDestination)}`;
return {
consoleUrl,
expiresAt: Expiration ?? new Date(Date.now() + sessionDuration * 1000)
};
};
@@ -0,0 +1,110 @@
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { PamResource } from "../pam-resource-enums";
import {
TPamResourceFactory,
TPamResourceFactoryRotateAccountCredentials,
TPamResourceFactoryValidateAccountCredentials
} from "../pam-resource-types";
import { validatePamRoleConnection, validateTargetRoleAssumption } from "./aws-iam-federation";
import { TAwsIamAccountCredentials, TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types";
export const awsIamResourceFactory: TPamResourceFactory<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 },
"[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 (ARN: ${connectionDetails.roleArn}) has permission to assume it.`
});
}
logger.info(
{ targetRoleArn: credentials.targetRoleArn },
"[AWS IAM Resource Factory] Target role credentials validated successfully"
);
return credentials;
} catch (error) {
if (error instanceof BadRequestError) {
throw error;
}
logger.error(error, "[AWS IAM Resource Factory] Failed to validate target role credentials");
throw new BadRequestError({
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
});
}
};
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<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,24 @@
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
import { AwsIamResourceListItemSchema } from "./aws-iam-resource-schemas";
export const getAwsIamResourceListItem = () => {
return {
name: AwsIamResourceListItemSchema.shape.name.value,
resource: AwsIamResourceListItemSchema.shape.resource.value
};
};
/**
* Extract the AWS Account ID from an IAM Role ARN
* ARN format: arn:aws:iam::123456789012:role/RoleName
*/
export const extractAwsAccountIdFromArn = (roleArn: string): string => {
const match = roleArn.match(new RE2("^arn:aws:iam::(\\d{12}):role/"));
if (!match) {
throw new BadRequestError({ message: "Invalid IAM Role ARN format" });
}
return match[1];
};
@@ -0,0 +1,81 @@
import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
} from "../pam-resource-schemas";
// AWS STS session duration limits (in seconds)
// Role chaining (Infisical → PAM role → target role) limits max session to 1 hour
// @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
const AWS_STS_MIN_SESSION_DURATION = 900; // 15 minutes
const AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING = 3600; // 1 hour
export const AwsIamResourceConnectionDetailsSchema = z.object({
roleArn: z.string().trim().min(1)
});
export const AwsIamAccountCredentialsSchema = z.object({
targetRoleArn: z.string().trim().min(1).max(2048),
defaultSessionDuration: z.coerce
.number()
.min(AWS_STS_MIN_SESSION_DURATION)
.max(AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING)
});
const BaseAwsIamResourceSchema = BasePamResourceSchema.extend({
resourceType: z.literal(PamResource.AwsIam),
gatewayId: z.string().uuid().nullable().optional()
});
export const AwsIamResourceSchema = BaseAwsIamResourceSchema.extend({
connectionDetails: AwsIamResourceConnectionDetailsSchema,
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
});
export const SanitizedAwsIamResourceSchema = BaseAwsIamResourceSchema.extend({
connectionDetails: AwsIamResourceConnectionDetailsSchema,
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
});
export const AwsIamResourceListItemSchema = z.object({
name: z.literal("AWS IAM"),
resource: z.literal(PamResource.AwsIam)
});
export const CreateAwsIamResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: AwsIamResourceConnectionDetailsSchema,
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
});
export const UpdateAwsIamResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: AwsIamResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
});
export const AwsIamAccountSchema = BasePamAccountSchema.extend({
credentials: AwsIamAccountCredentialsSchema
});
export const CreateAwsIamAccountSchema = BaseCreatePamAccountSchema.extend({
credentials: AwsIamAccountCredentialsSchema,
// AWS IAM accounts don't support credential rotation - they use role assumption
rotationEnabled: z.boolean().default(false)
});
export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({
credentials: AwsIamAccountCredentialsSchema.optional()
});
export const SanitizedAwsIamAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
credentials: AwsIamAccountCredentialsSchema.pick({
targetRoleArn: true,
defaultSessionDuration: true
})
});
@@ -0,0 +1,16 @@
import { z } from "zod";
import {
AwsIamAccountCredentialsSchema,
AwsIamAccountSchema,
AwsIamResourceConnectionDetailsSchema,
AwsIamResourceSchema
} from "./aws-iam-resource-schemas";
// Resources
export type TAwsIamResource = z.infer<typeof AwsIamResourceSchema>;
export type TAwsIamResourceConnectionDetails = z.infer<typeof AwsIamResourceConnectionDetailsSchema>;
// Accounts
export type TAwsIamAccount = z.infer<typeof AwsIamAccountSchema>;
export type TAwsIamAccountCredentials = z.infer<typeof AwsIamAccountCredentialsSchema>;
@@ -0,0 +1,5 @@
export * from "./aws-iam-federation";
export * from "./aws-iam-resource-factory";
export * from "./aws-iam-resource-fns";
export * from "./aws-iam-resource-schemas";
export * from "./aws-iam-resource-types";
@@ -2,13 +2,13 @@ import { z } from "zod";
import { PamResource } from "../pam-resource-enums"; import { PamResource } from "../pam-resource-enums";
import { import {
BaseCreateGatewayPamResourceSchema,
BaseCreatePamAccountSchema, BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema, BasePamAccountSchema,
BasePamAccountSchemaWithResource, BasePamAccountSchemaWithResource,
BasePamResourceSchema, BasePamResourceSchema,
BaseUpdatePamAccountSchema, BaseUpdateGatewayPamResourceSchema,
BaseUpdatePamResourceSchema BaseUpdatePamAccountSchema
} from "../pam-resource-schemas"; } from "../pam-resource-schemas";
import { import {
BaseSqlAccountCredentialsSchema, BaseSqlAccountCredentialsSchema,
@@ -43,12 +43,12 @@ export const MySQLResourceListItemSchema = z.object({
resource: z.literal(PamResource.MySQL) resource: z.literal(PamResource.MySQL)
}); });
export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({ export const CreateMySQLResourceSchema = BaseCreateGatewayPamResourceSchema.extend({
connectionDetails: MySQLResourceConnectionDetailsSchema, connectionDetails: MySQLResourceConnectionDetailsSchema,
rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional()
}); });
export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({ export const UpdateMySQLResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({
connectionDetails: MySQLResourceConnectionDetailsSchema.optional(), connectionDetails: MySQLResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional()
}); });
@@ -14,7 +14,7 @@ export const pamResourceDALFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex) => { const findById = async (id: string, tx?: Knex) => {
const doc = await (tx || db.replicaNode())(TableName.PamResource) 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(selectAllTableCols(TableName.PamResource))
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
@@ -1,7 +1,8 @@
export enum PamResource { export enum PamResource {
Postgres = "postgres", Postgres = "postgres",
MySQL = "mysql", MySQL = "mysql",
SSH = "ssh" SSH = "ssh",
AwsIam = "aws-iam"
} }
export enum PamResourceOrderBy { export enum PamResourceOrderBy {
@@ -1,3 +1,4 @@
import { awsIamResourceFactory } from "./aws-iam/aws-iam-resource-factory";
import { PamResource } from "./pam-resource-enums"; import { PamResource } from "./pam-resource-enums";
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; 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> = { export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
[PamResource.MySQL]: 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 { KmsDataKey } from "@app/services/kms/kms-types";
import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; 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 { getMySQLResourceListItem } from "./mysql/mysql-resource-fns";
import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
export const listResourceOptions = () => { 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 // Resource
@@ -3,6 +3,18 @@ import { z } from "zod";
import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas"; import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas";
import { slugSchema } from "@app/server/lib/schemas"; import { slugSchema } from "@app/server/lib/schemas";
export const GatewayAccessResponseSchema = z.object({
sessionId: z.string(),
relayClientCertificate: z.string(),
relayClientPrivateKey: z.string(),
relayServerCertificateChain: z.string(),
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string(),
metadata: z.record(z.string(), z.string().optional()).optional()
});
// Resources // Resources
export const BasePamResourceSchema = PamResourcesSchema.omit({ export const BasePamResourceSchema = PamResourcesSchema.omit({
encryptedConnectionDetails: true, encryptedConnectionDetails: true,
@@ -10,17 +22,27 @@ export const BasePamResourceSchema = PamResourcesSchema.omit({
resourceType: true resourceType: true
}); });
export const BaseCreatePamResourceSchema = z.object({ const CoreCreatePamResourceSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
gatewayId: z.string().uuid(),
name: slugSchema({ field: "name" }) name: slugSchema({ field: "name" })
}); });
export const BaseUpdatePamResourceSchema = z.object({ export const BaseCreateGatewayPamResourceSchema = CoreCreatePamResourceSchema.extend({
gatewayId: z.string().uuid().optional(), gatewayId: z.string().uuid()
});
export const BaseCreatePamResourceSchema = CoreCreatePamResourceSchema;
const CoreUpdatePamResourceSchema = z.object({
name: slugSchema({ field: "name" }).optional() name: slugSchema({ field: "name" }).optional()
}); });
export const BaseUpdateGatewayPamResourceSchema = CoreUpdatePamResourceSchema.extend({
gatewayId: z.string().uuid().optional()
});
export const BaseUpdatePamResourceSchema = CoreUpdatePamResourceSchema;
// Accounts // Accounts
export const BasePamAccountSchema = PamAccountsSchema.omit({ export const BasePamAccountSchema = PamAccountsSchema.omit({
encryptedCredentials: true encryptedCredentials: true
@@ -92,7 +92,8 @@ export const pamResourceServiceFactory = ({
resourceType, resourceType,
connectionDetails, connectionDetails,
gatewayId, gatewayId,
gatewayV2Service gatewayV2Service,
projectId
); );
const validatedConnectionDetails = await factory.validateConnection(); const validatedConnectionDetails = await factory.validateConnection();
@@ -162,7 +163,8 @@ export const pamResourceServiceFactory = ({
resource.resourceType as PamResource, resource.resourceType as PamResource,
connectionDetails, connectionDetails,
resource.gatewayId, resource.gatewayId,
gatewayV2Service gatewayV2Service,
resource.projectId
); );
const validatedConnectionDetails = await factory.validateConnection(); const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({ const encryptedConnectionDetails = await encryptResourceConnectionDetails({
@@ -189,7 +191,8 @@ export const pamResourceServiceFactory = ({
resource.resourceType as PamResource, resource.resourceType as PamResource,
decryptedConnectionDetails, decryptedConnectionDetails,
resource.gatewayId, resource.gatewayId,
gatewayV2Service gatewayV2Service,
resource.projectId
); );
let finalCredentials = { ...rotationAccountCredentials }; let finalCredentials = { ...rotationAccountCredentials };
@@ -1,6 +1,12 @@
import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import {
TAwsIamAccount,
TAwsIamAccountCredentials,
TAwsIamResource,
TAwsIamResourceConnectionDetails
} from "./aws-iam/aws-iam-resource-types";
import { import {
TMySQLAccount, TMySQLAccount,
TMySQLAccountCredentials, TMySQLAccountCredentials,
@@ -22,22 +28,28 @@ import {
} from "./ssh/ssh-resource-types"; } from "./ssh/ssh-resource-types";
// Resource types // Resource types
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource;
export type TPamResourceConnectionDetails = export type TPamResourceConnectionDetails =
| TPostgresResourceConnectionDetails | TPostgresResourceConnectionDetails
| TMySQLResourceConnectionDetails | TMySQLResourceConnectionDetails
| TSSHResourceConnectionDetails; | TSSHResourceConnectionDetails
| TAwsIamResourceConnectionDetails;
// Account types // Account types
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount;
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials; export type TPamAccountCredentials =
| TPostgresAccountCredentials
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
| TMySQLAccountCredentials
| TSSHAccountCredentials
| TAwsIamAccountCredentials;
// Resource DTOs // Resource DTOs
export type TCreateResourceDTO = Pick< export type TCreateResourceDTO = Pick<TPamResource, "name" | "connectionDetails" | "resourceType" | "projectId"> & {
TPamResource, gatewayId?: string | null;
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" rotationAccountCredentials?: TPamAccountCredentials | null;
>; };
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & { export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
resourceId: string; resourceId: string;
@@ -65,8 +77,9 @@ export type TPamResourceFactoryRotateAccountCredentials<C extends TPamAccountCre
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = ( export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
resourceType: PamResource, resourceType: PamResource,
connectionDetails: T, connectionDetails: T,
gatewayId: string, gatewayId: string | null | undefined,
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId"> gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
projectId: string | null | undefined
) => { ) => {
validateConnection: TPamResourceFactoryValidateConnection<T>; validateConnection: TPamResourceFactoryValidateConnection<T>;
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
@@ -2,13 +2,13 @@ import { z } from "zod";
import { PamResource } from "../pam-resource-enums"; import { PamResource } from "../pam-resource-enums";
import { import {
BaseCreateGatewayPamResourceSchema,
BaseCreatePamAccountSchema, BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema, BasePamAccountSchema,
BasePamAccountSchemaWithResource, BasePamAccountSchemaWithResource,
BasePamResourceSchema, BasePamResourceSchema,
BaseUpdatePamAccountSchema, BaseUpdateGatewayPamResourceSchema,
BaseUpdatePamResourceSchema BaseUpdatePamAccountSchema
} from "../pam-resource-schemas"; } from "../pam-resource-schemas";
import { import {
BaseSqlAccountCredentialsSchema, BaseSqlAccountCredentialsSchema,
@@ -40,12 +40,12 @@ export const PostgresResourceListItemSchema = z.object({
resource: z.literal(PamResource.Postgres) resource: z.literal(PamResource.Postgres)
}); });
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ export const CreatePostgresResourceSchema = BaseCreateGatewayPamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema, connectionDetails: PostgresResourceConnectionDetailsSchema,
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
}); });
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ export const UpdatePostgresResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), connectionDetails: PostgresResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
}); });
@@ -233,6 +233,10 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
gatewayV2Service gatewayV2Service
) => { ) => {
const validateConnection = async () => { const validateConnection = async () => {
if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required" });
}
try { try {
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => {
await client.validate(true); await client.validate(true);
@@ -255,6 +259,10 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
credentials credentials
) => { ) => {
try { try {
if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required" });
}
await executeWithGateway( await executeWithGateway(
{ {
connectionDetails, connectionDetails,
@@ -296,6 +304,10 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
currentCredentials currentCredentials
) => { ) => {
const newPassword = alphaNumericNanoId(32); const newPassword = alphaNumericNanoId(32);
if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required" });
}
try { try {
return await executeWithGateway( return await executeWithGateway(
{ {
@@ -60,6 +60,10 @@ export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetai
) => { ) => {
const validateConnection = async () => { const validateConnection = async () => {
try { try {
if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required" });
}
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const client = new Client(); const client = new Client();
@@ -131,6 +135,10 @@ export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetai
credentials credentials
) => { ) => {
try { try {
if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required" });
}
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const client = new Client(); const client = new Client();
@@ -2,13 +2,13 @@ import { z } from "zod";
import { PamResource } from "../pam-resource-enums"; import { PamResource } from "../pam-resource-enums";
import { import {
BaseCreateGatewayPamResourceSchema,
BaseCreatePamAccountSchema, BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema, BasePamAccountSchema,
BasePamAccountSchemaWithResource, BasePamAccountSchemaWithResource,
BasePamResourceSchema, BasePamResourceSchema,
BaseUpdatePamAccountSchema, BaseUpdateGatewayPamResourceSchema,
BaseUpdatePamResourceSchema BaseUpdatePamAccountSchema
} from "../pam-resource-schemas"; } from "../pam-resource-schemas";
import { SSHAuthMethod } from "./ssh-resource-enums"; import { SSHAuthMethod } from "./ssh-resource-enums";
@@ -73,12 +73,12 @@ export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({
.optional() .optional()
}); });
export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({ export const CreateSSHResourceSchema = BaseCreateGatewayPamResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema, connectionDetails: SSHResourceConnectionDetailsSchema,
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
}); });
export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({ export const UpdateSSHResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema.optional(), connectionDetails: SSHResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
}); });
@@ -4,6 +4,8 @@ import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { PamSessionStatus } from "./pam-session-enums";
export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>; export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>;
export const pamSessionDALFactory = (db: TDbClient) => { export const pamSessionDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamSession); const orm = ormify(db, TableName.PamSession);
@@ -22,5 +24,19 @@ export const pamSessionDALFactory = (db: TDbClient) => {
return session; return session;
}; };
return { ...orm, findById }; const expireSessionById = async (sessionId: string, tx?: Knex) => {
const now = new Date();
const updatedCount = await (tx || db)(TableName.PamSession)
.where("id", sessionId)
.whereIn("status", [PamSessionStatus.Active, PamSessionStatus.Starting])
.update({
status: PamSessionStatus.Ended,
endedAt: now
});
return updatedCount;
};
return { ...orm, findById, expireSessionById };
}; };
@@ -1,6 +1,6 @@
export enum PamSessionStatus { export enum PamSessionStatus {
Starting = "starting", // Starting, user connecting to resource Starting = "starting", // Starting, user connecting to resource
Active = "active", // Active, user is connected to resource Active = "active", // Active, user is connected to resource
Ended = "ended", // Ended by user Ended = "ended", // Ended by user or automatically expired after expiresAt timestamp
Terminated = "terminated" // Terminated by an admin Terminated = "terminated" // Terminated by an admin
} }
@@ -34,9 +34,40 @@ export const pamSessionServiceFactory = ({
licenseService, licenseService,
kmsService kmsService
}: TPamSessionServiceFactoryDep) => { }: 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.Ended,
endedAt: new Date()
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return { ...session, ...updatedSession };
}
return session;
};
const getById = async (sessionId: string, actor: OrgServiceActor) => { const getById = async (sessionId: string, actor: OrgServiceActor) => {
const session = await pamSessionDAL.findById(sessionId); const sessionFromDb = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); if (!sessionFromDb) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const session = await checkAndExpireSessionIfNeeded(sessionFromDb);
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor: actor.type, actor: actor.type,
@@ -116,7 +147,7 @@ export const pamSessionServiceFactory = ({
OrgPermissionSubjects.Gateway 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" }); throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" });
} }
@@ -158,7 +189,7 @@ export const pamSessionServiceFactory = ({
OrgPermissionSubjects.Gateway 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" }); throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" });
} }
} else if (actor.type === ActorType.USER) { } else if (actor.type === ActorType.USER) {
+4
View File
@@ -286,6 +286,10 @@ const envSchema = z
DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default( DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default(
process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY
), ),
// PAM AWS credentials (for AWS IAM PAM resource type)
PAM_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()),
PAM_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()),
/* ----------------------------------------------------------------------------- */ /* ----------------------------------------------------------------------------- */
/* App Connections ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */
+6
View File
@@ -83,6 +83,7 @@ export enum QueueName {
HealthAlert = "health-alert", HealthAlert = "health-alert",
CertificateV3AutoRenewal = "certificate-v3-auto-renewal", CertificateV3AutoRenewal = "certificate-v3-auto-renewal",
PamAccountRotation = "pam-account-rotation", PamAccountRotation = "pam-account-rotation",
PamSessionExpiration = "pam-session-expiration",
PkiAcmeChallengeValidation = "pki-acme-challenge-validation" PkiAcmeChallengeValidation = "pki-acme-challenge-validation"
} }
@@ -138,6 +139,7 @@ export enum QueueJobs {
HealthAlert = "health-alert", HealthAlert = "health-alert",
CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal",
PamAccountRotation = "pam-account-rotation", PamAccountRotation = "pam-account-rotation",
PamSessionExpiration = "pam-session-expiration",
PkiAcmeChallengeValidation = "pki-acme-challenge-validation" PkiAcmeChallengeValidation = "pki-acme-challenge-validation"
} }
@@ -404,6 +406,10 @@ export type TQueueJobTypes = {
name: QueueJobs.PamAccountRotation; name: QueueJobs.PamAccountRotation;
payload: undefined; payload: undefined;
}; };
[QueueName.PamSessionExpiration]: {
name: QueueJobs.PamSessionExpiration;
payload: { sessionId: string };
};
[QueueName.PkiAcmeChallengeValidation]: { [QueueName.PkiAcmeChallengeValidation]: {
name: QueueJobs.PkiAcmeChallengeValidation; name: QueueJobs.PkiAcmeChallengeValidation;
payload: { challengeId: string }; payload: { challengeId: string };
+8 -1
View File
@@ -290,6 +290,7 @@ import { orgServiceFactory } from "@app/services/org/org-service";
import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; 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 { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
@@ -2429,6 +2430,10 @@ export const registerRoutes = async (
}); });
const approvalPolicyDAL = approvalPolicyDALFactory(db); const approvalPolicyDAL = approvalPolicyDALFactory(db);
const pamSessionExpirationService = pamSessionExpirationServiceFactory({
queueService,
pamSessionDAL
});
const pamAccountService = pamAccountServiceFactory({ const pamAccountService = pamAccountServiceFactory({
pamAccountDAL, pamAccountDAL,
@@ -2443,7 +2448,8 @@ export const registerRoutes = async (
userDAL, userDAL,
auditLogService, auditLogService,
approvalRequestGrantsDAL, approvalRequestGrantsDAL,
approvalPolicyDAL approvalPolicyDAL,
pamSessionExpirationService
}); });
const pamAccountRotation = pamAccountRotationServiceFactory({ const pamAccountRotation = pamAccountRotationServiceFactory({
@@ -2531,6 +2537,7 @@ export const registerRoutes = async (
await healthAlert.init(); await healthAlert.init();
await pkiSyncCleanup.init(); await pkiSyncCleanup.init();
await pamAccountRotation.init(); await pamAccountRotation.init();
await pamSessionExpirationService.init();
await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startDailyRemindersJob();
await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts(); await dailyExpiringPkiItemAlert.startSendingAlerts();
@@ -2,6 +2,8 @@ import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas";
import { UnpackedPermissionSchema } from "./permission"; import { UnpackedPermissionSchema } from "./permission";
export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.omit({
projectMembershipId: true
}).extend({
permissions: UnpackedPermissionSchema.array() permissions: UnpackedPermissionSchema.array()
}); });
@@ -79,7 +79,10 @@ export const additionalPrivilegeServiceFactory = ({
}); });
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
} }
@@ -103,7 +106,10 @@ export const additionalPrivilegeServiceFactory = ({
}); });
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
}; };
@@ -136,7 +142,10 @@ export const additionalPrivilegeServiceFactory = ({
}); });
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
} }
@@ -158,7 +167,10 @@ export const additionalPrivilegeServiceFactory = ({
}); });
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
}; };
@@ -179,7 +191,10 @@ export const additionalPrivilegeServiceFactory = ({
const additionalPrivilege = await additionalPrivilegeDAL.deleteById(existingPrivilege.id); const additionalPrivilege = await additionalPrivilegeDAL.deleteById(existingPrivilege.id);
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
}; };
@@ -199,7 +214,10 @@ export const additionalPrivilegeServiceFactory = ({
throw new NotFoundError({ message: `Additional privilege with id ${selector.id} doesn't exist` }); throw new NotFoundError({ message: `Additional privilege with id ${selector.id} doesn't exist` });
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
}; };
@@ -219,7 +237,10 @@ export const additionalPrivilegeServiceFactory = ({
throw new NotFoundError({ message: `Additional privilege with name ${selector.name} doesn't exist` }); throw new NotFoundError({ message: `Additional privilege with name ${selector.name} doesn't exist` });
return { return {
additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } additionalPrivilege: {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
}
}; };
}; };
@@ -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
};
};
@@ -5,7 +5,6 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
} }
export type TIdentityProjectPrivilege = { export type TIdentityProjectPrivilege = {
projectMembershipId: string;
slug: string; slug: string;
id: string; id: string;
createdAt: Date; createdAt: Date;
+2 -1
View File
@@ -16,7 +16,8 @@ export enum PamResourceType {
CockroachDB = "cockroachdb", CockroachDB = "cockroachdb",
Elasticsearch = "elasticsearch", Elasticsearch = "elasticsearch",
Snowflake = "snowflake", Snowflake = "snowflake",
DynamoDB = "dynamodb" DynamoDB = "dynamodb",
AwsIam = "aws-iam"
} }
export enum PamResourceOrderBy { export enum PamResourceOrderBy {
+2 -1
View File
@@ -20,5 +20,6 @@ export const PAM_RESOURCE_TYPE_MAP: Record<
[PamResourceType.CockroachDB]: { name: "CockroachDB", image: "CockroachDB.png" }, [PamResourceType.CockroachDB]: { name: "CockroachDB", image: "CockroachDB.png" },
[PamResourceType.Elasticsearch]: { name: "Elasticsearch", image: "Elastic.png" }, [PamResourceType.Elasticsearch]: { name: "Elasticsearch", image: "Elastic.png" },
[PamResourceType.Snowflake]: { name: "Snowflake", image: "Snowflake.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" }
}; };
+39
View File
@@ -120,6 +120,45 @@ export const useDeletePamAccount = () => {
}); });
}; };
export type TAccessPamAccountDTO = {
accountId: string;
accountPath: string;
projectId: string;
duration: string;
};
export type TAccessPamAccountResponse = {
sessionId: string;
resourceType: string;
consoleUrl?: string;
metadata?: Record<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, accountPath, projectId, duration }: TAccessPamAccountDTO) => {
const { data } = await apiRequest.post<TAccessPamAccountResponse>(
"/api/v1/pam/accounts/access",
{
accountId,
accountPath,
projectId,
duration
}
);
return data;
}
});
};
// Folders // Folders
export const useCreatePamFolder = () => { export const useCreatePamFolder = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -0,0 +1,25 @@
import { PamResourceType } from "../enums";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
export type TAwsIamConnectionDetails = {
roleArn: string;
};
export type TAwsIamCredentials = {
targetRoleArn: string;
defaultSessionDuration: number;
};
export type TAwsIamResource = Omit<TBasePamResource, "gatewayId"> & {
resourceType: PamResourceType.AwsIam;
gatewayId?: string | null;
connectionDetails: TAwsIamConnectionDetails;
};
export type TAwsIamAccount = Omit<
TBasePamAccount,
"rotationEnabled" | "rotationIntervalSeconds" | "lastRotatedAt"
> & {
credentials: TAwsIamCredentials;
};
+4 -2
View File
@@ -6,17 +6,19 @@ import {
PamResourceType, PamResourceType,
PamSessionStatus PamSessionStatus
} from "../enums"; } from "../enums";
import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource";
export * from "./aws-iam-resource";
export * from "./mysql-resource"; export * from "./mysql-resource";
export * from "./postgres-resource"; export * from "./postgres-resource";
export * from "./ssh-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 = { export type TPamFolder = {
id: string; id: string;
@@ -23,18 +23,17 @@ export const PamAccessAccountModal = ({
projectId, projectId,
accountPath accountPath
}: Props) => { }: Props) => {
let fullAccountPath = account?.name; const [duration, setDuration] = useState("4h");
if (accountPath) {
let path = accountPath;
if (path.startsWith("/")) path = path.slice(1);
fullAccountPath = `${path}/${account?.name}`;
}
const { protocol, hostname, port } = window.location; const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : ""; const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`; const siteURL = `${protocol}//${hostname}${portSuffix}`;
const [duration, setDuration] = useState("4h"); let fullAccountPath = account?.name ?? "";
if (accountPath) {
const path = accountPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${account?.name ?? ""}`;
}
const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]); const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]);
@@ -89,7 +88,7 @@ export const PamAccessAccountModal = ({
default: default:
return ""; return "";
} }
}, [account, cliDuration]); }, [account, fullAccountPath, projectId, cliDuration, siteURL]);
if (!account) return null; if (!account) return null;
@@ -0,0 +1,216 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
ModalClose
} from "@app/components/v2";
import { CopyButton } from "@app/components/v2/CopyButton";
import { useProject } from "@app/context";
import {
PamResourceType,
TAwsIamAccount,
TAwsIamResource,
useGetPamResourceById
} from "@app/hooks/api/pam";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
type Props = {
account?: TAwsIamAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<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)
defaultSessionDuration: z.coerce
.number()
.min(900, "Minimum session duration is 900 seconds (15 minutes)")
.max(3600, "Maximum session duration is 3600 seconds (1 hour)")
.default(3600)
});
const formSchema = genericAccountFieldsSchema.extend({
credentials: AwsIamCredentialsSchema
});
type FormData = z.infer<typeof formSchema>;
export const AwsIamAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const { projectId } = useProject();
const resourceIdToFetch = account?.resourceId || resourceId;
const resourceTypeToFetch = account?.resource?.resourceType || resourceType;
const { data: resource } = useGetPamResourceById(resourceTypeToFetch, resourceIdToFetch, {
enabled: !!resourceIdToFetch && !!resourceTypeToFetch
});
const pamRoleArn =
(resource?.resourceType === PamResourceType.AwsIam &&
(resource as TAwsIamResource).connectionDetails?.roleArn) ||
"arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PAM_ROLE_NAME>";
const targetRoleTrustPolicy = `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "${pamRoleArn}"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "${projectId}"
}
}
}]
}`;
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: account ?? {
name: "",
description: "",
credentials: {
targetRoleArn: "",
defaultSessionDuration: 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.defaultSessionDuration"
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="Default Session Duration (seconds)"
>
<Input {...field} type="number" placeholder="3600" />
</FormControl>
)}
/>
</div>
<Accordion
type="single"
collapsible
className="mb-4 w-full rounded-r border-l-2 border-l-primary bg-mineshaft-300/5"
>
<AccordionItem value="target-role-setup" className="border-b-0">
<AccordionTrigger className="px-4 py-2.5 hover:no-underline [&[data-state=open]]:pb-1">
<div className="flex items-center text-sm transition-colors duration-150 hover:text-primary">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
Target Role Setup
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2.5">
<p className="mb-3 text-sm text-mineshaft-300">
The target role must have a trust policy that allows the PAM role (created in the
&quot;Resources&quot; tab) to assume it. If your target role name follows the
wildcard pattern you defined in the PAM role&apos;s permissions policy, no
additional changes are needed.
</p>
<p className="mb-2 text-sm font-medium text-mineshaft-200">
Target role trust policy:
</p>
<div className="relative mb-3">
<div className="absolute top-1 right-3">
<CopyButton value={targetRoleTrustPolicy} size="sm" variant="plain" />
</div>
<pre className="max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
{targetRoleTrustPolicy}
</pre>
</div>
<p className="text-xs text-mineshaft-400">
<strong>Note:</strong> The Principal role ARN shown above is from the PAM Resource
selected for this account. The External ID{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">{projectId}</code> is your
current project ID. If your target role name doesn&apos;t match the wildcard pattern
in your PAM Resource&apos;s role&apos;s permissions policy, you&apos;ll need to
update that policy to include this role&apos;s ARN.
</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 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 { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader"; import { PamAccountHeader } from "../PamAccountHeader";
import { AwsIamAccountForm } from "./AwsIamAccountForm";
import { MySQLAccountForm } from "./MySQLAccountForm"; import { MySQLAccountForm } from "./MySQLAccountForm";
import { PostgresAccountForm } from "./PostgresAccountForm"; import { PostgresAccountForm } from "./PostgresAccountForm";
import { SshAccountForm } from "./SshAccountForm"; import { SshAccountForm } from "./SshAccountForm";
@@ -70,6 +71,14 @@ const CreateForm = ({
return ( return (
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} /> <SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
); );
case PamResourceType.AwsIam:
return (
<AwsIamAccountForm
onSubmit={onSubmit}
resourceId={resourceId}
resourceType={resourceType}
/>
);
default: default:
throw new Error(`Unhandled resource: ${resourceType}`); throw new Error(`Unhandled resource: ${resourceType}`);
} }
@@ -100,6 +109,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />; return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.SSH: case PamResourceType.SSH:
return <SshAccountForm account={account as any} onSubmit={onSubmit} />; return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.AwsIam:
return <AwsIamAccountForm account={account as any} onSubmit={onSubmit} />;
default: default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`); throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
} }
@@ -41,6 +41,7 @@ type Props = {
search: string; search: string;
isFlatView: boolean; isFlatView: boolean;
accountPath?: string; accountPath?: string;
isAccessLoading?: boolean;
}; };
export const PamAccountRow = ({ export const PamAccountRow = ({
@@ -50,7 +51,8 @@ export const PamAccountRow = ({
onUpdate, onUpdate,
onDelete, onDelete,
isFlatView, isFlatView,
accountPath accountPath,
isAccessLoading
}: Props) => { }: Props) => {
const { id, name } = account; const { id, name } = account;
@@ -101,7 +103,7 @@ export const PamAccountRow = ({
</span> </span>
</Badge> </Badge>
)} )}
{account.lastRotatedAt && ( {"lastRotatedAt" in account && account.lastRotatedAt && (
<Tooltip <Tooltip
className="max-w-sm text-center" className="max-w-sm text-center"
isDisabled={!account.lastRotationMessage} isDisabled={!account.lastRotationMessage}
@@ -127,6 +129,8 @@ export const PamAccountRow = ({
leftIcon={<FontAwesomeIcon icon={faRightToBracket} />} leftIcon={<FontAwesomeIcon icon={faRightToBracket} />}
onClick={() => onAccess(account)} onClick={() => onAccess(account)}
size="xs" size="xs"
isLoading={isAccessLoading}
isDisabled={isAccessLoading}
> >
Access Access
</Button> </Button>
@@ -52,6 +52,8 @@ import {
PAM_RESOURCE_TYPE_MAP, PAM_RESOURCE_TYPE_MAP,
PamAccountOrderBy, PamAccountOrderBy,
PamAccountView, PamAccountView,
PamResourceType,
TPamAccount,
TPamFolder TPamFolder
} from "@app/hooks/api/pam"; } from "@app/hooks/api/pam";
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries"; import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
@@ -67,6 +69,7 @@ import { PamDeleteFolderModal } from "./PamDeleteFolderModal";
import { PamFolderRow } from "./PamFolderRow"; import { PamFolderRow } from "./PamFolderRow";
import { PamUpdateAccountModal } from "./PamUpdateAccountModal"; import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
import { PamUpdateFolderModal } from "./PamUpdateFolderModal"; import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
import { useAccessAwsIamAccount } from "./useAccessAwsIamAccount";
type PamAccountFilter = { type PamAccountFilter = {
resourceIds: string[]; resourceIds: string[];
@@ -78,6 +81,7 @@ type Props = {
export const PamAccountsTable = ({ projectId }: Props) => { export const PamAccountsTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path }); const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
const { accessAwsIam, loadingAccountId } = useAccessAwsIamAccount();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"misc", "misc",
@@ -419,8 +423,21 @@ export const PamAccountsTable = ({ projectId }: Props) => {
search={search} search={search}
isFlatView={accountView === PamAccountView.Flat} isFlatView={accountView === PamAccountView.Flat}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined} accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e) => { isAccessLoading={loadingAccountId === account.id}
handlePopUpOpen("accessAccount", e); onAccess={(e: TPamAccount) => {
// For AWS IAM, directly open console without modal
if (e.resource.resourceType === PamResourceType.AwsIam) {
let fullAccountPath = e?.name;
const folderPath = e.folderId ? folderPaths[e.folderId] : undefined;
if (folderPath) {
const path = folderPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${e?.name}`;
}
accessAwsIam(e, fullAccountPath);
} else {
handlePopUpOpen("accessAccount", e);
}
}} }}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)} onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)} onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
@@ -14,36 +14,6 @@ type Props = {
currentFolderId: string | null; 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 = ({ export const PamAddAccountModal = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
@@ -51,22 +21,44 @@ export const PamAddAccountModal = ({
onComplete, onComplete,
currentFolderId currentFolderId
}: Props) => { }: 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 ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={handleOpenChange}>
<ModalContent <ModalContent
className="max-w-2xl" className="max-w-2xl"
title="Add Account" title="Add Account"
subTitle="Select a resource to add an account under." subTitle="Select a resource to add an account under."
bodyClassName="overflow-visible" bodyClassName={selectedResource ? undefined : "overflow-visible"}
> >
<Content {selectedResource ? (
projectId={projectId} <PamAccountForm
onComplete={(account) => { onComplete={(account) => {
if (onComplete) onComplete(account); if (onComplete) onComplete(account);
onOpenChange(false); onOpenChange(false);
}} }}
currentFolderId={currentFolderId} 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> </ModalContent>
</Modal> </Modal>
); );
@@ -80,6 +80,8 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
return; return;
} }
// Clear search when a value is selected so the selected label is shown
setSearch("");
onChange(newValue); onChange(newValue);
}} }}
isLoading={isPending} isLoading={isPending}
@@ -0,0 +1,54 @@
import { useState } from "react";
import { createNotification } from "@app/components/notifications";
import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam";
import { TAwsIamCredentials } from "@app/hooks/api/pam/types";
export const useAccessAwsIamAccount = () => {
const accessPamAccount = useAccessPamAccount();
const [loadingAccountId, setLoadingAccountId] = useState<string | null>(null);
const accessAwsIam = async (account: TPamAccount, accountPath: string) => {
if (account.resource.resourceType !== PamResourceType.AwsIam) {
return false;
}
setLoadingAccountId(account.id);
try {
const response = await accessPamAccount.mutateAsync({
accountId: account.id,
accountPath,
projectId: account.projectId,
duration: `${(account.credentials as TAwsIamCredentials).defaultSessionDuration}s`
});
if (response.consoleUrl) {
// Open the AWS Console URL in a new tab
window.open(response.consoleUrl, "_blank", "noopener,noreferrer");
createNotification({
text: "AWS Console opened in new tab",
type: "success"
});
return true;
}
createNotification({
text: "Failed to generate AWS Console URL",
type: "error"
});
return false;
} finally {
setLoadingAccountId(null);
}
};
return {
accessAwsIam,
isPending: accessPamAccount.isPending,
loadingAccountId
};
};
@@ -0,0 +1,224 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
ModalClose
} from "@app/components/v2";
import { CopyButton } from "@app/components/v2/CopyButton";
import { useProject } from "@app/context";
import { PamResourceType, TAwsIamResource } from "@app/hooks/api/pam";
import { slugSchema } from "@app/lib/schemas";
type Props = {
resource?: TAwsIamResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/;
const AwsIamConnectionDetailsSchema = z.object({
roleArn: z
.string()
.trim()
.min(1, "PAM Role ARN is required")
.refine((val) => arnRoleRegex.test(val), {
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
})
});
const formSchema = z.object({
name: slugSchema({ min: 1, max: 64, field: "Name" }),
resourceType: z.literal(PamResourceType.AwsIam),
connectionDetails: AwsIamConnectionDetailsSchema
});
type FormData = z.infer<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 permissionsPolicy = `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PREFIX>-*"
}]
}`;
const trustPolicy = `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<INFISICAL_AWS_ACCOUNT_ID>:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "${projectId}"
}
}
}]
}`;
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.AwsIam,
connectionDetails: {
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.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 rounded-r border-l-2 border-l-primary bg-mineshaft-300/5"
>
<AccordionItem value="aws-iam-role-setup" className="border-b-0">
<AccordionTrigger className="px-4 py-2.5 hover:no-underline [&[data-state=open]]:pb-1">
<div className="flex items-center text-sm transition-colors duration-150 hover:text-primary">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
AWS IAM Role Setup
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2.5">
<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 a
wildcard pattern (e.g.,{" "}
<code className="rounded bg-mineshaft-700 px-1 text-xs">pam-*</code> or{" "}
<code className="rounded bg-mineshaft-700 px-1 text-xs">privileged-*</code>) so you
can add new accounts without updating this policy. Choose a prefix that fits your
naming conventions.
</p>
<div className="relative mb-4">
<div className="absolute top-1 right-1">
<CopyButton value={permissionsPolicy} size="sm" variant="plain" />
</div>
<pre className="max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
{permissionsPolicy}
</pre>
</div>
<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>
<div className="relative mb-4">
<div className="absolute top-1 right-3">
<CopyButton value={trustPolicy} size="sm" variant="plain" />
</div>
<pre className="max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
{trustPolicy}
</pre>
</div>
<p className="text-xs text-mineshaft-400">
<strong>Note:</strong> Use{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">
{INFISICAL_AWS_ACCOUNT_US}
</code>{" "}
for US region or{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">
{INFISICAL_AWS_ACCOUNT_EU}
</code>{" "}
for EU region. Replace{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">
&lt;INFISICAL_AWS_ACCOUNT_ID&gt;
</code>{" "}
with the appropriate Infisical AWS account ID for your region. The External ID{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">{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 { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader"; import { PamResourceHeader } from "../PamResourceHeader";
import { AwsIamResourceForm } from "./AwsIamResourceForm";
import { MySQLResourceForm } from "./MySQLResourceForm"; import { MySQLResourceForm } from "./MySQLResourceForm";
import { PostgresResourceForm } from "./PostgresResourceForm"; import { PostgresResourceForm } from "./PostgresResourceForm";
import { SSHResourceForm } from "./SSHResourceForm"; import { SSHResourceForm } from "./SSHResourceForm";
@@ -54,6 +55,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
return <MySQLResourceForm onSubmit={onSubmit} />; return <MySQLResourceForm onSubmit={onSubmit} />;
case PamResourceType.SSH: case PamResourceType.SSH:
return <SSHResourceForm onSubmit={onSubmit} />; return <SSHResourceForm onSubmit={onSubmit} />;
case PamResourceType.AwsIam:
return <AwsIamResourceForm onSubmit={onSubmit} />;
default: default:
throw new Error(`Unhandled resource: ${resourceType}`); throw new Error(`Unhandled resource: ${resourceType}`);
} }
@@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />; return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.SSH: case PamResourceType.SSH:
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />; return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.AwsIam:
return <AwsIamResourceForm resource={resource} onSubmit={onSubmit} />;
default: default:
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`); 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 { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
import { CommandLogView } from "./CommandLogView"; import { CommandLogView } from "./CommandLogView";
@@ -13,6 +16,7 @@ export const PamSessionLogsSection = ({ session }: Props) => {
const isDatabaseSession = const isDatabaseSession =
session.resourceType === PamResourceType.Postgres || session.resourceType === PamResourceType.Postgres ||
session.resourceType === PamResourceType.MySQL; session.resourceType === PamResourceType.MySQL;
const isAwsIamSession = session.resourceType === PamResourceType.AwsIam;
const hasLogs = session.logs.length > 0; const hasLogs = session.logs.length > 0;
return ( return (
@@ -23,7 +27,27 @@ export const PamSessionLogsSection = ({ session }: Props) => {
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />} {isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />} {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="flex grow items-center justify-center text-bunker-300">
<div className="text-center"> <div className="text-center">
<div className="mb-2">Session logs are not yet available</div> <div className="mb-2">Session logs are not yet available</div>