mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-03 10:26:00 +00:00
Merge branch 'main' into PLATFRM-114
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId");
|
||||||
|
if (hasGatewayId) {
|
||||||
|
await knex.schema.alterTable(TableName.PamResource, (t) => {
|
||||||
|
t.uuid("gatewayId").nullable().alter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId");
|
||||||
|
if (hasGatewayId) {
|
||||||
|
await knex.schema.alterTable(TableName.PamResource, (t) => {
|
||||||
|
t.uuid("gatewayId").notNullable().alter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export const PamResourcesSchema = z.object({
|
|||||||
id: z.string().uuid(),
|
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) {
|
||||||
|
|||||||
@@ -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 ----------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+216
@@ -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
|
||||||
|
"Resources" tab) to assume it. If your target role name follows the
|
||||||
|
wildcard pattern you defined in the PAM role's permissions policy, no
|
||||||
|
additional changes are needed.
|
||||||
|
</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't match the wildcard pattern
|
||||||
|
in your PAM Resource's role's permissions policy, you'll need to
|
||||||
|
update that policy to include this role's ARN.
|
||||||
|
</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
|
||||||
|
};
|
||||||
|
};
|
||||||
+224
@@ -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">
|
||||||
|
<INFISICAL_AWS_ACCOUNT_ID>
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user