From f0edcd1f0f1d52f386df9929be95fe2657e885cd Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 16 Oct 2025 18:12:03 -0400 Subject: [PATCH 01/32] feat(pam): account credential rotation --- .../20251015042917_pam-account-rotation.ts | 43 +++++++ backend/src/db/schemas/pam-accounts.ts | 5 +- backend/src/db/schemas/pam-resources.ts | 4 +- .../pam-account-endpoints.ts | 12 +- .../pam-resource-endpoints.ts | 2 + .../ee/services/audit-log/audit-log-types.ts | 16 +++ .../services/pam-account/pam-account-dal.ts | 16 ++- .../pam-account/pam-account-service.ts | 114 +++++++++++++++++- .../services/pam-account/pam-account-types.ts | 5 +- .../services/pam-resource/pam-resource-fns.ts | 10 +- .../pam-resource/pam-resource-schemas.ts | 9 +- .../pam-resource/pam-resource-service.ts | 60 ++++++++- .../pam-resource/pam-resource-types.ts | 7 +- .../postgres/postgres-resource-schemas.ts | 14 ++- .../shared/sql/sql-resource-factory.ts | 67 +++++++++- backend/src/queue/queue-service.ts | 10 +- backend/src/server/routes/index.ts | 10 +- .../pam-account-rotation-queue.ts | 61 ++++++++++ .../src/hooks/api/pam/types/base-account.ts | 3 + .../hooks/api/pam/types/postgres-resource.ts | 1 + .../PamAccountForm/PamAccountForm.tsx | 10 +- .../PamAccountForm/PostgresAccountForm.tsx | 4 +- .../PamAccountForm/RotateAccountFields.tsx | 71 +++++++++++ .../PamResourceForm/PostgresResourceForm.tsx | 6 +- .../shared/SqlRotateAccountFields.tsx | 64 ++++++++++ 25 files changed, 588 insertions(+), 36 deletions(-) create mode 100644 backend/src/db/migrations/20251015042917_pam-account-rotation.ts create mode 100644 backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx diff --git a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts new file mode 100644 index 000000000..cd9a7d19a --- /dev/null +++ b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if ( + !(await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled")) && + !(await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) && + !(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt")) + ) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.boolean("rotationEnabled").notNullable().defaultTo(false); + t.integer("rotationIntervalSeconds").notNullable().defaultTo(2592000); + t.timestamp("lastRotatedAt").nullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials"))) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.binary("encryptedRotationAccountCredentials").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials")) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.dropColumn("encryptedRotationAccountCredentials"); + }); + } + + if ( + (await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled")) && + (await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) && + (await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt")) + ) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("lastRotatedAt"); + t.dropColumn("rotationIntervalSeconds"); + t.dropColumn("rotationEnabled"); + }); + } +} diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 5a9a45617..9bcfa5cec 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -18,7 +18,10 @@ export const PamAccountsSchema = z.object({ description: z.string().nullable().optional(), encryptedCredentials: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + rotationEnabled: z.boolean().default(false), + rotationIntervalSeconds: z.number().default(2592000), + lastRotatedAt: z.date().nullable().optional() }); export type TPamAccounts = z.infer; diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts index d34017d0f..929a31c79 100644 --- a/backend/src/db/schemas/pam-resources.ts +++ b/backend/src/db/schemas/pam-resources.ts @@ -17,7 +17,9 @@ export const PamResourcesSchema = z.object({ resourceType: z.string(), encryptedConnectionDetails: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + encryptedRotationAccountDetails: zodBuffer.nullable().optional(), + encryptedRotationAccountCredentials: zodBuffer.nullable().optional() }); export type TPamResources = z.infer; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index 0ed7e238a..dccaaa8ca 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -22,11 +22,15 @@ export const registerPamResourceEndpoints = ({ folderId?: C["folderId"]; name: C["name"]; description?: C["description"]; + rotationEnabled: C["rotationEnabled"]; + rotationIntervalSeconds: C["rotationIntervalSeconds"]; }>; updateAccountSchema: z.ZodType<{ credentials?: C["credentials"]; name?: C["name"]; description?: C["description"]; + rotationEnabled?: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; accountResponseSchema: z.ZodTypeAny; }) => { @@ -60,7 +64,9 @@ export const registerPamResourceEndpoints = ({ resourceType, folderId: req.body.folderId, name: req.body.name, - description: req.body.description + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds } } }); @@ -108,7 +114,9 @@ export const registerPamResourceEndpoints = ({ resourceId: account.resourceId, resourceType, name: req.body.name, - description: req.body.description + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds } } }); diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts index 776de8e48..ffbeae5c0 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -21,11 +21,13 @@ export const registerPamResourceEndpoints = ({ connectionDetails: T["connectionDetails"]; gatewayId: T["gatewayId"]; name: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; updateResourceSchema: z.ZodType<{ connectionDetails?: T["connectionDetails"]; gatewayId?: T["gatewayId"]; name?: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; resourceResponseSchema: z.ZodTypeAny; }) => { diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index bc50283d4..ff2dafa9f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -515,6 +515,7 @@ export enum EventType { PAM_ACCOUNT_CREATE = "pam-account-create", PAM_ACCOUNT_UPDATE = "pam-account-update", PAM_ACCOUNT_DELETE = "pam-account-delete", + PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation", PAM_RESOURCE_LIST = "pam-resource-list", PAM_RESOURCE_GET = "pam-resource-get", PAM_RESOURCE_CREATE = "pam-resource-create", @@ -3795,6 +3796,8 @@ interface PamAccountCreateEvent { folderId?: string | null; name: string; description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds: number; }; } @@ -3806,6 +3809,8 @@ interface PamAccountUpdateEvent { resourceType: string; name?: string; description?: string | null; + rotationEnabled?: boolean; + rotationIntervalSeconds?: number; }; } @@ -3819,6 +3824,16 @@ interface PamAccountDeleteEvent { }; } +interface PamAccountCredentialRotationEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + }; +} + interface PamResourceListEvent { type: EventType.PAM_RESOURCE_LIST; metadata: { @@ -4209,6 +4224,7 @@ export type Event = | PamAccountCreateEvent | PamAccountUpdateEvent | PamAccountDeleteEvent + | PamAccountCredentialRotationEvent | PamResourceListEvent | PamResourceGetEvent | PamResourceCreateEvent diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts index b62e940fe..c422c2b1d 100644 --- a/backend/src/ee/services/pam-account/pam-account-dal.ts +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -39,5 +39,19 @@ export const pamAccountDALFactory = (db: TDbClient) => { })); }; - return { ...orm, findWithResourceDetails }; + const findAccountsDueForRotation = async (tx?: Knex) => { + const dbClient = tx || db.replicaNode(); + + const accounts = await dbClient(TableName.PamAccount) + .innerJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .whereNotNull(`${TableName.PamResource}.encryptedRotationAccountCredentials`) + .whereRaw( + `COALESCE("${TableName.PamAccount}"."lastRotatedAt", "${TableName.PamAccount}"."createdAt") + "${TableName.PamAccount}"."rotationIntervalSeconds" * interval '1 second' < NOW()` + ) + .select(selectAllTableCols(TableName.PamAccount)); + + return accounts; + }; + + return { ...orm, findWithResourceDetails, findAccountsDueForRotation }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index e9ea76e8c..6146c1a13 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -11,12 +11,14 @@ import { } from "@app/ee/services/permission/project-permission"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; @@ -45,10 +47,12 @@ type TPamAccountServiceFactoryDep = { "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" >; userDAL: TUserDALFactory; + auditLogService: Pick; }; - export type TPamAccountServiceFactory = ReturnType; +const ROTATION_CONCURRENCY_LIMIT = 10; + export const pamAccountServiceFactory = ({ pamResourceDAL, pamSessionDAL, @@ -59,10 +63,19 @@ export const pamAccountServiceFactory = ({ permissionService, licenseService, kmsService, - gatewayV2Service + gatewayV2Service, + auditLogService }: TPamAccountServiceFactoryDep) => { const create = async ( - { credentials, resourceId, name, description, folderId }: TCreateAccountDTO, + { + credentials, + resourceId, + name, + description, + folderId, + rotationEnabled, + rotationIntervalSeconds + }: TCreateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -84,6 +97,10 @@ export const pamAccountServiceFactory = ({ actionProjectType: ActionProjectType.PAM }); + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + const accountPath = await getFullPamFolderPath({ pamFolderDAL, folderId, @@ -126,7 +143,9 @@ export const pamAccountServiceFactory = ({ encryptedCredentials, name, description, - folderId + folderId, + rotationEnabled, + rotationIntervalSeconds }); return { @@ -145,7 +164,7 @@ export const pamAccountServiceFactory = ({ }; const updateById = async ( - { accountId, credentials, description, name }: TUpdateAccountDTO, + { accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -195,6 +214,17 @@ export const pamAccountServiceFactory = ({ updateDoc.description = description; } + if (rotationEnabled !== undefined) { + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + updateDoc.rotationEnabled = rotationEnabled; + } + + if (rotationIntervalSeconds !== undefined) { + updateDoc.rotationIntervalSeconds = rotationIntervalSeconds; + } + if (credentials !== undefined) { const connectionDetails = await decryptResourceConnectionDetails({ projectId: account.projectId, @@ -516,12 +546,84 @@ export const pamAccountServiceFactory = ({ }; }; + const rotateAllDueAccounts = async () => { + const accounts = await pamAccountDAL.findAccountsDueForRotation(); + + for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) { + const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); + + const rotationPromises = batch.map(async (account) => { + try { + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource || !resource.encryptedRotationAccountCredentials) return; + + const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + if (!rotationAccountCredentials) return; + + const accountCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resourceType as PamResource]( + resourceType as PamResource, + connectionDetails, + gatewayId, + gatewayV2Service + ); + + const newCredentials = await factory.rotateAccountCredentials(rotationAccountCredentials, accountCredentials); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: newCredentials, + projectId: account.projectId, + kmsService + }); + + await pamAccountDAL.updateById(account.id, { + encryptedCredentials, + lastRotatedAt: new Date() + }); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: resource.id, + resourceType: resource.resourceType + } + } + }); + } catch (error) { + logger.error(error, `Failed to rotate credentials for account ${account.id}`); + } + }); + + // eslint-disable-next-line no-await-in-loop + await Promise.all(rotationPromises); + } + }; + return { create, updateById, deleteById, list, access, - getSessionCredentials + getSessionCredentials, + rotateAllDueAccounts }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 514d7d780..4bbccc6fa 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -1,7 +1,10 @@ import { TPamAccount } from "../pam-resource/pam-resource-types"; // DTOs -export type TCreateAccountDTO = Pick; +export type TCreateAccountDTO = Pick< + TPamAccount, + "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" +>; export type TUpdateAccountDTO = Partial> & { accountId: string; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index 1d79e892e..9d7493e68 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -2,6 +2,7 @@ import { TPamResources } from "@app/db/schemas"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; @@ -63,6 +64,13 @@ export const decryptResource = async ( encryptedConnectionDetails: resource.encryptedConnectionDetails, projectId, kmsService - }) + }), + rotationAccountCredentials: resource.encryptedRotationAccountCredentials + ? await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId, + kmsService + }) + : null } as TPamResource; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 80a50a9a4..5b0199166 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -6,6 +6,7 @@ import { slugSchema } from "@app/server/lib/schemas"; // Resources export const BasePamResourceSchema = PamResourcesSchema.omit({ encryptedConnectionDetails: true, + encryptedRotationAccountCredentials: true, resourceType: true }); @@ -37,10 +38,14 @@ export const BaseCreatePamAccountSchema = z.object({ resourceId: z.string().uuid(), folderId: z.string().uuid().optional(), name: slugSchema({ field: "name" }), - description: z.string().max(512).nullable().optional() + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean(), + rotationIntervalSeconds: z.number().min(3600) }); export const BaseUpdatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }).optional(), - description: z.string().max(512).nullable().optional() + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean().optional(), + rotationIntervalSeconds: z.number().min(3600).optional() }); diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 312795a50..1cc3ca135 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -10,10 +10,16 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; +import { encryptAccountCredentials } from "../pam-account/pam-account-fns"; import { TPamResourceDALFactory } from "./pam-resource-dal"; import { PamResource } from "./pam-resource-enums"; import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; -import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; +import { + decryptResource, + decryptResourceConnectionDetails, + encryptResourceConnectionDetails, + listResourceOptions +} from "./pam-resource-fns"; import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; type TPamResourceServiceFactoryDep = { @@ -61,7 +67,7 @@ export const pamResourceServiceFactory = ({ }; const create = async ( - { resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO, + { resourceType, connectionDetails, gatewayId, name, projectId, rotationAccountCredentials }: TCreateResourceDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -88,26 +94,42 @@ export const pamResourceServiceFactory = ({ gatewayId, gatewayV2Service ); - const validatedConnectionDetails = await factory.validateConnection(); + const validatedConnectionDetails = await factory.validateConnection(); const encryptedConnectionDetails = await encryptResourceConnectionDetails({ connectionDetails: validatedConnectionDetails, projectId, kmsService }); + let encryptedRotationAccountCredentials: Buffer | null = null; + + if (rotationAccountCredentials) { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(rotationAccountCredentials); + + encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId, + kmsService + }); + } + const resource = await pamResourceDAL.create({ resourceType, encryptedConnectionDetails, gatewayId, name, - projectId + projectId, + encryptedRotationAccountCredentials }); return decryptResource(resource, projectId, kmsService); }; - const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { + const updateById = async ( + { connectionDetails, resourceId, name, rotationAccountCredentials }: TUpdateResourceDTO, + actor: OrgServiceActor + ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); if (!orgLicensePlan.pam) { throw new BadRequestError({ @@ -151,6 +173,34 @@ export const pamResourceServiceFactory = ({ updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; } + if (rotationAccountCredentials !== undefined) { + updateDoc.encryptedRotationAccountCredentials = null; + + if (rotationAccountCredentials) { + const decryptedConnectionDetails = await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + projectId: resource.projectId, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + decryptedConnectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + const validatedRotationAccountCredentials = + await factory.validateAccountCredentials(rotationAccountCredentials); + + updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + } + } + // If nothing was updated, return the fetched resource if (Object.keys(updateDoc).length === 0) { return decryptResource(resource, resource.projectId, kmsService); diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index fb1b669ed..f2016420a 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -18,7 +18,7 @@ export type TPamAccountCredentials = TPostgresAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< TPamResource, - "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" + "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" >; export type TUpdateResourceDTO = Partial> & { @@ -30,6 +30,10 @@ export type TPamResourceFactoryValidateConnection = ( credentials: C ) => Promise; +export type TPamResourceFactoryRotateAccountCredentials = ( + rotationAccountCredentials: C, + currentCredentials: C +) => Promise; export type TPamResourceFactory = ( resourceType: PamResource, @@ -39,4 +43,5 @@ export type TPamResourceFactory { validateConnection: TPamResourceFactoryValidateConnection; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; + rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials; }; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts index a97e3f2e7..f383f521a 100644 --- a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -15,13 +15,15 @@ import { BaseSqlResourceConnectionDetailsSchema } from "../shared/sql/sql-resource-schemas"; -// Resources export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; +export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; +// Resources const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) }); export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); export const PostgresResourceListItemSchema = z.object({ @@ -30,16 +32,16 @@ export const PostgresResourceListItemSchema = z.object({ }); export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema.optional() + connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); // Accounts -export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; - export const PostgresAccountSchema = BasePamAccountSchema.extend({ credentials: PostgresAccountCredentialsSchema }); diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 74a2c74ae..73defd6e6 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -6,9 +6,14 @@ import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2 import { BadRequestError } from "@app/lib/errors"; import { GatewayProxyProtocol } from "@app/lib/gateway"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { PamResource } from "../../pam-resource-enums"; -import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../../pam-resource-types"; import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; @@ -176,8 +181,66 @@ export const sqlResourceFactory: TPamResourceFactory = async ( + rotationAccountCredentials, + currentCredentials + ) => { + try { + const newPassword = alphaNumericNanoId(32); + + await executeWithGateway( + { + connectionDetails, + gatewayId, + resourceType, + username: rotationAccountCredentials.username, + password: rotationAccountCredentials.password + }, + gatewayV2Service, + async (client) => { + switch (resourceType) { + case PamResource.Postgres: + await client.raw(`ALTER USER ?? WITH PASSWORD '${newPassword}'`, [currentCredentials.username]); + break; + default: + throw new BadRequestError({ + message: `Password rotation for ${resourceType as PamResource} is not supported.` + }); + } + } + ); + + return { username: currentCredentials.username, password: newPassword }; + } catch (error) { + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${rotationAccountCredentials.username}"`) { + throw new BadRequestError({ + message: "Management credentials invalid: Username or password incorrect" + }); + } + + if (error.message.includes("permission denied")) { + throw new BadRequestError({ + message: `Management credentials lack permission to rotate password for user "${currentCredentials.username}"` + }); + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + return { validateConnection, - validateAccountCredentials + validateAccountCredentials, + rotateAccountCredentials }; }; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7f45e3821..9d8c472f4 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -77,7 +77,8 @@ export enum QueueName { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + PamAccountRotation = "pam-account-rotation" } export enum QueueJobs { @@ -126,7 +127,8 @@ export enum QueueJobs { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification-job", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + PamAccountRotation = "pam-account-rotation" } export type TQueueJobTypes = { @@ -357,6 +359,10 @@ export type TQueueJobTypes = { name: QueueJobs.HealthAlert; payload: undefined; }; + [QueueName.PamAccountRotation]: { + name: QueueJobs.PamAccountRotation; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e95dd501c..448564704 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -250,6 +250,7 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -2193,7 +2194,13 @@ export const registerRoutes = async ( pamSessionDAL, permissionService, projectDAL, - userDAL + userDAL, + auditLogService + }); + + const pamAccountRotation = pamAccountRotationServiceFactory({ + queueService, + pamAccountService }); const pamSessionService = pamSessionServiceFactory({ @@ -2220,6 +2227,7 @@ export const registerRoutes = async ( await dailyResourceCleanUp.init(); await healthAlert.init(); await pkiSyncCleanup.init(); + await pamAccountRotation.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); diff --git a/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts new file mode 100644 index 000000000..fb265ac56 --- /dev/null +++ b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts @@ -0,0 +1,61 @@ +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type TPamAccountRotationServiceFactoryDep = { + queueService: TQueueServiceFactory; + pamAccountService: Pick; +}; + +export type TPamAccountRotationServiceFactory = ReturnType; + +export const pamAccountRotationServiceFactory = ({ + queueService, + pamAccountService +}: TPamAccountRotationServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.PamAccountRotation, + QueueJobs.PamAccountRotation, + { pattern: "0 * * * *", utc: true }, + QueueName.PamAccountRotation // job id + ); + + await queueService.startPg( + QueueJobs.PamAccountRotation, + async () => { + try { + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task started`); + await pamAccountService.rotateAllDueAccounts(); + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task completed`); + } catch (error) { + logger.error(error, `${QueueName.PamAccountRotation}: pam account rotation failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 1 // 5 * 60 + } + ); + + await queueService.schedulePg( + QueueJobs.PamAccountRotation, + "0 * * * *", // Schedule to run every hour + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 9f45b1a4a..ec94d51b2 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -12,6 +12,9 @@ export interface TBasePamAccount { }; name: string; description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds: number; + lastRotatedAt?: string | null; createdAt: string; updatedAt: string; } diff --git a/frontend/src/hooks/api/pam/types/postgres-resource.ts b/frontend/src/hooks/api/pam/types/postgres-resource.ts index 513610be1..b1b5b7487 100644 --- a/frontend/src/hooks/api/pam/types/postgres-resource.ts +++ b/frontend/src/hooks/api/pam/types/postgres-resource.ts @@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource"; // Resources export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & { connectionDetails: TBaseSqlConnectionDetails; + rotationAccountCredentials?: TBaseSqlCredentials | null; }; // Accounts diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index ead2b06e1..bb64e33c0 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -37,7 +37,10 @@ const CreateForm = ({ console.log({ folderId }); const onSubmit = async ( - formData: DiscriminativePick + formData: DiscriminativePick< + TPamAccount, + "name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds" + > ) => { try { const account = await createPamAccount.mutateAsync({ @@ -74,7 +77,10 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { const updatePamAccount = useUpdatePamAccount(); const onSubmit = async ( - formData: DiscriminativePick + formData: DiscriminativePick< + TPamAccount, + "name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds" + > ) => { try { const updatedAccount = await updatePamAccount.mutateAsync({ diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index 5bcd459aa..0033c48f3 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -8,13 +8,14 @@ import { TPostgresAccount } from "@app/hooks/api/pam"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { SqlAccountFields } from "./shared/SqlAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields"; type Props = { account?: TPostgresAccount; onSubmit: (formData: FormData) => Promise; }; -const formSchema = genericAccountFieldsSchema.extend({ +const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({ credentials: BaseSqlAccountSchema }); @@ -50,6 +51,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => { > +
From ebb28844eed1adc852f68b6da79d892195e8e87d Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 17 Oct 2025 22:54:22 -0400 Subject: [PATCH 06/32] make resource rotation account credentials write-only --- .../routes/v1/pam-resource-routers/index.ts | 4 +-- .../pam-resource-router.ts | 6 ++-- .../pam-resource/pam-resource-service.ts | 17 ++++++++-- .../postgres/postgres-resource-schemas.ts | 9 ++++++ .../components/PamAccountRow.tsx | 2 +- .../PamResourceForm/PostgresResourceForm.tsx | 32 ++++++++++++------- .../shared/SqlRotateAccountFields.tsx | 21 +++++++++--- 7 files changed, 65 insertions(+), 26 deletions(-) diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index a63b67d94..6b53781ae 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,7 +1,7 @@ import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresResourceSchema, - PostgresResourceSchema, + SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; @@ -12,7 +12,7 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { }), response: { 200: z.object({ - resources: ResourceSchema.array() + resources: SanitizedResourceSchema.array() }) } }, diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 1c5ead252..076ec6855 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -10,7 +10,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; -import { encryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { decryptAccountCredentials, encryptAccountCredentials } from "../pam-account/pam-account-fns"; import { TPamResourceDALFactory } from "./pam-resource-dal"; import { PamResource } from "./pam-resource-enums"; import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; @@ -192,8 +192,19 @@ export const pamResourceServiceFactory = ({ gatewayV2Service ); - const validatedRotationAccountCredentials = - await factory.validateAccountCredentials(rotationAccountCredentials); + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...rotationAccountCredentials }; + if (resource.encryptedRotationAccountCredentials && rotationAccountCredentials.password === "******") { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials); updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({ credentials: validatedRotationAccountCredentials, diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts index f383f521a..bbe83a3a4 100644 --- a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -26,6 +26,15 @@ export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); +export const SanitizedPostgresResourceSchema = BasePostgresResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() +}); + export const PostgresResourceListItemSchema = z.object({ name: z.literal("PostgreSQL"), resource: z.literal(PamResource.Postgres) diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx index 758ef2c20..8e7507ac9 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx @@ -11,8 +11,8 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { twMerge } from "tailwind-merge"; import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx index c172bb561..427fff40b 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx @@ -31,17 +31,25 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => { const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: resource ?? { - resourceType: PamResourceType.Postgres, - connectionDetails: { - host: "", - port: 5432, - database: "default", - sslEnabled: true, - sslRejectUnauthorized: true, - sslCertificate: undefined - } - } + defaultValues: resource + ? { + ...resource, + rotationAccountCredentials: { + ...resource.rotationAccountCredentials, + password: "******" + } + } + : { + resourceType: PamResourceType.Postgres, + connectionDetails: { + host: "", + port: 5432, + database: "default", + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } }); const { @@ -62,7 +70,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => { selectedTabIndex={selectedTabIndex} setSelectedTabIndex={setSelectedTabIndex} /> - +
)} From 3a61ce07365fc6f5ec0dcda06c3ec6476c8b6dac Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 20 Oct 2025 21:44:39 -0300 Subject: [PATCH 10/32] Lint fix --- .../components/SecretDropzone/SecretDropzone.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index 5d8ea59c3..ded34f9d4 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -38,12 +38,12 @@ import { fetchProjectSecrets, mergePersonalSecrets } from "@app/hooks/api/secret import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { - PopUpNames, - usePopUpAction, - useBatchModeActions, + BatchContext, PendingSecretCreate, PendingSecretUpdate, - BatchContext + PopUpNames, + useBatchModeActions, + usePopUpAction } from "../../SecretMainPage.store"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; import { PasteSecretEnvModal } from "./PasteSecretEnvModal"; From 64b3d6132599bfa9a163f5f20f86a0cb42131b95 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 20:45:54 -0400 Subject: [PATCH 11/32] tweak pam resource query --- frontend/src/hooks/api/pam/queries.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index c6df40e37..60ead74ec 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -12,7 +12,7 @@ export const pamKeys = { session: () => [...pamKeys.all, "session"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], - getResource: (resourceId?: string) => [...pamKeys.resource(), "get", resourceId], + getResource: (resourceId: string) => [...pamKeys.resource(), "get", resourceId], listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] @@ -77,7 +77,7 @@ export const useGetPamResourceById = ( > ) => { return useQuery({ - queryKey: pamKeys.getResource(resourceId), + queryKey: pamKeys.getResource(resourceId || ""), queryFn: async () => { const { data } = await apiRequest.get<{ resource: TPamResource }>( `/api/v1/pam/resources/${resourceId}` From 2b6fcb2564d61a35fd76f7ce6d665f8f633942e1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 20:46:15 -0400 Subject: [PATCH 12/32] fix rotation account configured detection --- backend/src/ee/services/pam-resource/pam-resource-schemas.ts | 2 ++ .../components/PamAccountForm/PostgresAccountForm.tsx | 2 +- .../components/PamAccountForm/RotateAccountFields.tsx | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 5b0199166..8f4752f4e 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -31,6 +31,8 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ id: true, name: true, resourceType: true + }).extend({ + rotationCredentialsConfigured: z.boolean() }) }); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index bbf7982fb..5a2104275 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -54,7 +54,7 @@ export const PostgresAccountForm = ({ account, resourceId, onSubmit }: Props) => } else { setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials); } - }, [account]); + }, [account, resource]); return ( diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx index 32a7040ec..2739e1cd5 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx @@ -22,7 +22,10 @@ export const RotateAccountFields = ({ const rotationEnabled = watch("rotationEnabled"); return ( - +
Date: Mon, 20 Oct 2025 21:03:35 -0400 Subject: [PATCH 13/32] fix endpoint and improve frontend performance --- frontend/src/hooks/api/pam/queries.tsx | 8 +++++--- .../components/PamAccountForm/PamAccountForm.tsx | 8 +++++++- .../components/PamAccountForm/PostgresAccountForm.tsx | 9 ++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index 60ead74ec..22810c4e3 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -4,6 +4,7 @@ import { apiRequest } from "@app/config/request"; import { TPamResourceOption } from "./types/resource-options"; import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; +import { PamResourceType } from "./enums"; export const pamKeys = { all: ["pam"] as const, @@ -70,22 +71,23 @@ export const useListPamResources = ( }; export const useGetPamResourceById = ( + resourceType?: PamResourceType, resourceId?: string, options?: Omit< UseQueryOptions>, - "queryKey" | "queryFn" | "enabled" + "queryKey" | "queryFn" > ) => { return useQuery({ queryKey: pamKeys.getResource(resourceId || ""), queryFn: async () => { const { data } = await apiRequest.get<{ resource: TPamResource }>( - `/api/v1/pam/resources/${resourceId}` + `/api/v1/pam/resources/${resourceType}/${resourceId}` ); return data.resource; }, - enabled: !!resourceId, + enabled: !!resourceId && !!resourceType && (options?.enabled ?? true), ...options }); }; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index eee101a54..8b553e656 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -65,7 +65,13 @@ const CreateForm = ({ switch (resourceType) { case PamResourceType.Postgres: - return ; + return ( + + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index 5a2104275..c353a278b 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, ModalClose } from "@app/components/v2"; -import { TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam"; +import { PamResourceType, TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { SqlAccountFields } from "./shared/SqlAccountFields"; @@ -14,6 +14,7 @@ import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountF type Props = { account?: TPostgresAccount; resourceId?: string; + resourceType?: PamResourceType; onSubmit: (formData: FormData) => Promise; }; @@ -23,7 +24,7 @@ const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.s type FormData = z.infer; -export const PostgresAccountForm = ({ account, resourceId, onSubmit }: Props) => { +export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => { const isUpdate = Boolean(account); const form = useForm({ @@ -46,7 +47,9 @@ export const PostgresAccountForm = ({ account, resourceId, onSubmit }: Props) => const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false); - const { data: resource } = useGetPamResourceById(resourceId); + const { data: resource } = useGetPamResourceById(resourceType, resourceId, { + enabled: !account && !!resourceId && !!resourceType + }); useEffect(() => { if (account) { From bdbec5b1589320d705f5acf9ab7069b0daed0810 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 21:09:01 -0400 Subject: [PATCH 14/32] add resource type to error log --- backend/src/ee/services/audit-log/audit-log-types.ts | 1 + backend/src/ee/services/pam-account/pam-account-service.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index be5906af5..94561e6d8 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3841,6 +3841,7 @@ interface PamAccountCredentialRotationFailedEvent { accountName: string; accountId: string; resourceId: string; + resourceType: string; errorMessage: string; }; } diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 209eb25b9..4d0ccc6ab 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -554,9 +554,11 @@ export const pamAccountServiceFactory = ({ const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); const rotationPromises = batch.map(async (account) => { + let logResourceType = "unknown"; try { const resource = await pamResourceDAL.findById(account.resourceId); if (!resource || !resource.encryptedRotationAccountCredentials) return; + logResourceType = resource.resourceType; const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource( resource, @@ -604,7 +606,7 @@ export const pamAccountServiceFactory = ({ accountId: account.id, accountName: account.name, resourceId: resource.id, - resourceType: resource.resourceType + resourceType: logResourceType } } }); @@ -623,6 +625,7 @@ export const pamAccountServiceFactory = ({ accountId: account.id, accountName: account.name, resourceId: account.resourceId, + resourceType: logResourceType, errorMessage } } From efd63dcb35b85e7751dc9ba0daf09282d25729a1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 21:13:40 -0400 Subject: [PATCH 15/32] improve rotation account error response --- .../src/ee/services/pam-resource/pam-resource-service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 683e0bca4..9ec702b24 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -213,12 +213,9 @@ export const pamResourceServiceFactory = ({ kmsService }); } catch (err) { - if ( - err instanceof BadRequestError && - err.message === "Account credentials invalid: Username or password incorrect" - ) { + if (err instanceof BadRequestError) { throw new BadRequestError({ - message: "Rotation Account credentials invalid: Username or password incorrect" + message: `Rotation Account Error: ${err.message}` }); } From 35d4720d8631288211a9a1a55c93b17464abf046 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 21:24:57 -0400 Subject: [PATCH 16/32] final small fixes --- .../services/pam-account/pam-account-service.ts | 4 ++-- .../CustomProviderAuditLogStreamForm.tsx | 15 +++++++++------ .../PamAccountForm/shared/SqlAccountFields.tsx | 12 +++++++----- .../shared/SqlRotateAccountFields.tsx | 12 +++++++----- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 4d0ccc6ab..83e488231 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -611,6 +611,8 @@ export const pamAccountServiceFactory = ({ } }); } catch (error) { + logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; await auditLogService.createAuditLog({ @@ -630,8 +632,6 @@ export const pamAccountServiceFactory = ({ } } }); - - logger.error(error, `Failed to rotate credentials for account ${account.id}`); } }); diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx index 7d2868dcc..b9a125900 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; import { Button, FormControl, FormLabel, IconButton, Input, ModalClose } from "@app/components/v2"; import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; import { TCustomProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/custom-provider"; +import { useState } from "react"; type Props = { auditLogStream?: TCustomProviderLogStream; @@ -29,6 +30,8 @@ const formSchema = z.object({ type FormData = z.infer; export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const [showPassword, setShowPassword] = useState(false); + const isUpdate = Boolean(auditLogStream); const form = useForm({ @@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P > { + placeholder="Bearer " + onFocus={() => { if ( auditLogStream && auditLogStream.credentials.headers[i] && @@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P ) { field.onChange(""); } - e.target.type = "text"; + setShowPassword(true); }} - onBlur={(e) => { + onBlur={() => { if ( auditLogStream && auditLogStream.credentials.headers[i] && @@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P ) { field.onChange("******"); } - e.target.type = "password"; + setShowPassword(false); }} /> diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx index c32102231..3e9db178f 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx @@ -1,9 +1,11 @@ import { Controller, useFormContext } from "react-hook-form"; import { FormControl, Input } from "@app/components/v2"; +import { useState } from "react"; export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { const { control } = useFormContext(); + const [showPassword, setShowPassword] = useState(false); return (
@@ -33,19 +35,19 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { > { + onFocus={() => { if (isUpdate && field.value === "******") { field.onChange(""); } - e.target.type = "text"; + setShowPassword(true); }} - onBlur={(e) => { + onBlur={() => { if (isUpdate && field.value === "") { field.onChange("******"); } - e.target.type = "password"; + setShowPassword(false); }} /> diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx index 05b28aa94..869594140 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { @@ -11,6 +12,7 @@ import { export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { const { control } = useFormContext(); + const [showPassword, setShowPassword] = useState(false); return ( @@ -50,19 +52,19 @@ export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { > { + onFocus={() => { if (isUpdate && field.value === "******") { field.onChange(""); } - e.target.type = "text"; + setShowPassword(true); }} - onBlur={(e) => { + onBlur={() => { if (isUpdate && field.value === "") { field.onChange("******"); } - e.target.type = "password"; + setShowPassword(false); }} /> From 8bbddb09c0a1b47cde961325bd6fa8837cbd806e Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 21:28:27 -0400 Subject: [PATCH 17/32] lint --- frontend/src/hooks/api/pam/queries.tsx | 2 +- .../AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx | 2 +- .../components/PamAccountForm/shared/SqlAccountFields.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index 22810c4e3..77afbe832 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -3,8 +3,8 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { TPamResourceOption } from "./types/resource-options"; -import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; import { PamResourceType } from "./enums"; +import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; export const pamKeys = { all: ["pam"] as const, diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx index b9a125900..e60f09c4a 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -7,7 +8,6 @@ import { z } from "zod"; import { Button, FormControl, FormLabel, IconButton, Input, ModalClose } from "@app/components/v2"; import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; import { TCustomProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/custom-provider"; -import { useState } from "react"; type Props = { auditLogStream?: TCustomProviderLogStream; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx index 3e9db178f..7d9e85c44 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx @@ -1,7 +1,7 @@ +import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { FormControl, Input } from "@app/components/v2"; -import { useState } from "react"; export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { const { control } = useFormContext(); From df069cbd5b5037a4c4b0d01ed5c4dc612e816b57 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 20 Oct 2025 21:34:28 -0400 Subject: [PATCH 18/32] tiny greptile improvement --- frontend/src/hooks/api/pam/queries.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index 77afbe832..6339b4761 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -13,7 +13,12 @@ export const pamKeys = { session: () => [...pamKeys.all, "session"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], - getResource: (resourceId: string) => [...pamKeys.resource(), "get", resourceId], + getResource: (resourceType: string, resourceId: string) => [ + ...pamKeys.resource(), + "get", + resourceType, + resourceId + ], listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] @@ -79,7 +84,7 @@ export const useGetPamResourceById = ( > ) => { return useQuery({ - queryKey: pamKeys.getResource(resourceId || ""), + queryKey: pamKeys.getResource(resourceType || "", resourceId || ""), queryFn: async () => { const { data } = await apiRequest.get<{ resource: TPamResource }>( `/api/v1/pam/resources/${resourceType}/${resourceId}` From 407a7e60758c6e1ac5f503e087e4a75264dfc917 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 21 Oct 2025 14:54:05 -0400 Subject: [PATCH 19/32] remove default from rotationIntervalSeconds --- .../src/db/migrations/20251015042917_pam-account-rotation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts index cd9a7d19a..eee9fd1fd 100644 --- a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts +++ b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts @@ -10,7 +10,7 @@ export async function up(knex: Knex): Promise { ) { await knex.schema.alterTable(TableName.PamAccount, (t) => { t.boolean("rotationEnabled").notNullable().defaultTo(false); - t.integer("rotationIntervalSeconds").notNullable().defaultTo(2592000); + t.integer("rotationIntervalSeconds").notNullable(); t.timestamp("lastRotatedAt").nullable(); }); } From 10cebfbbe29b824a6ef5b7def3e475e91195e19e Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 21 Oct 2025 16:37:08 -0300 Subject: [PATCH 20/32] Check identitiesUsed instead of identityLimit on getPlan --- backend/src/ee/services/license/license-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index fba9d0cca..817f4609c 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -212,9 +212,8 @@ export const licenseServiceFactory = ({ const membersUsed = await licenseDAL.countOfOrgMembers(orgId); currentPlan.membersUsed = membersUsed; const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); - currentPlan.identitiesUsed = identityUsed; - if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { + if (currentPlan?.identitiesUsed && currentPlan.identitiesUsed !== identityUsed) { try { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { quantity: membersUsed, @@ -227,6 +226,7 @@ export const licenseServiceFactory = ({ ); } } + currentPlan.identitiesUsed = identityUsed; await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), From 65b36e6232cfbfb4e4a3c88752a317c000babba6 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 21 Oct 2025 15:57:44 -0400 Subject: [PATCH 21/32] use __INFISICAL_UNCHANGED__ instead of empty string for unchanged placeholder --- .../pam-account/pam-account-service.ts | 23 +++++++++++++++---- .../pam-resource/pam-resource-service.ts | 5 +++- .../PamAccountForm/PostgresAccountForm.tsx | 2 +- .../shared/SqlAccountFields.tsx | 4 ++-- .../PamResourceForm/PostgresResourceForm.tsx | 2 +- .../shared/SqlRotateAccountFields.tsx | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 83e488231..2dfdec196 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -150,7 +150,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(account, resource.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; } catch (err) { if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { @@ -241,7 +246,7 @@ export const pamAccountServiceFactory = ({ // Logic to prevent overwriting unedited censored values const finalCredentials = { ...credentials }; - if (credentials.password === "******") { + if (credentials.password === "__INFISICAL_UNCHANGED__") { const decryptedCredentials = await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, projectId: account.projectId, @@ -269,7 +274,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; }; @@ -308,7 +318,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(deletedAccount, account.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 9ec702b24..d97905dbe 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -194,7 +194,10 @@ export const pamResourceServiceFactory = ({ // Logic to prevent overwriting unedited censored values const finalCredentials = { ...rotationAccountCredentials }; - if (resource.encryptedRotationAccountCredentials && rotationAccountCredentials.password === "******") { + if ( + resource.encryptedRotationAccountCredentials && + rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__" + ) { const decryptedCredentials = await decryptAccountCredentials({ encryptedCredentials: resource.encryptedRotationAccountCredentials, projectId: resource.projectId, diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index c353a278b..17d68bdf0 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -34,7 +34,7 @@ export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmi ...account, credentials: { ...account.credentials, - password: "******" + password: "__INFISICAL_UNCHANGED__" } } : undefined diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx index 7d9e85c44..0fd5760f3 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SqlAccountFields.tsx @@ -38,14 +38,14 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { type={showPassword ? "text" : "password"} autoComplete="new-password" onFocus={() => { - if (isUpdate && field.value === "******") { + if (isUpdate && field.value === "__INFISICAL_UNCHANGED__") { field.onChange(""); } setShowPassword(true); }} onBlur={() => { if (isUpdate && field.value === "") { - field.onChange("******"); + field.onChange("__INFISICAL_UNCHANGED__"); } setShowPassword(false); }} diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx index 20ac710c3..2c53c89df 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx @@ -37,7 +37,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => { rotationAccountCredentials: resource.rotationAccountCredentials ? { ...resource.rotationAccountCredentials, - password: "******" + password: "__INFISICAL_UNCHANGED__" } : resource.rotationAccountCredentials } diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx index 869594140..6ce92b8e6 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlRotateAccountFields.tsx @@ -55,14 +55,14 @@ export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { type={showPassword ? "text" : "password"} autoComplete="new-password" onFocus={() => { - if (isUpdate && field.value === "******") { + if (isUpdate && field.value === "__INFISICAL_UNCHANGED__") { field.onChange(""); } setShowPassword(true); }} onBlur={() => { if (isUpdate && field.value === "") { - field.onChange("******"); + field.onChange("__INFISICAL_UNCHANGED__"); } setShowPassword(false); }} From 85daaf806e536f749140a94ffc7bdc0c4085f41f Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 21 Oct 2025 16:21:52 -0400 Subject: [PATCH 22/32] Make rotationIntervalSeconds nullable --- .../db/migrations/20251015042917_pam-account-rotation.ts | 2 +- backend/src/db/schemas/pam-accounts.ts | 2 +- .../routes/v1/pam-account-routers/pam-account-endpoints.ts | 2 +- backend/src/ee/services/audit-log/audit-log-types.ts | 4 ++-- backend/src/ee/services/pam-account/pam-account-dal.ts | 1 + .../src/ee/services/pam-resource/pam-resource-schemas.ts | 4 ++-- frontend/src/hooks/api/pam/types/base-account.ts | 2 +- .../components/PamAccountForm/RotateAccountFields.tsx | 6 +++--- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts index eee9fd1fd..4e9fc90d4 100644 --- a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts +++ b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts @@ -10,7 +10,7 @@ export async function up(knex: Knex): Promise { ) { await knex.schema.alterTable(TableName.PamAccount, (t) => { t.boolean("rotationEnabled").notNullable().defaultTo(false); - t.integer("rotationIntervalSeconds").notNullable(); + t.integer("rotationIntervalSeconds").nullable(); t.timestamp("lastRotatedAt").nullable(); }); } diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 9bcfa5cec..7e78e0874 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -20,7 +20,7 @@ export const PamAccountsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), rotationEnabled: z.boolean().default(false), - rotationIntervalSeconds: z.number().default(2592000), + rotationIntervalSeconds: z.number().nullable().optional(), lastRotatedAt: z.date().nullable().optional() }); diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index dccaaa8ca..44e2a5ea1 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -23,7 +23,7 @@ export const registerPamResourceEndpoints = ({ name: C["name"]; description?: C["description"]; rotationEnabled: C["rotationEnabled"]; - rotationIntervalSeconds: C["rotationIntervalSeconds"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; updateAccountSchema: z.ZodType<{ credentials?: C["credentials"]; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 94561e6d8..8569d764b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3798,7 +3798,7 @@ interface PamAccountCreateEvent { name: string; description?: string | null; rotationEnabled: boolean; - rotationIntervalSeconds: number; + rotationIntervalSeconds?: number | null; }; } @@ -3811,7 +3811,7 @@ interface PamAccountUpdateEvent { name?: string; description?: string | null; rotationEnabled?: boolean; - rotationIntervalSeconds?: number; + rotationIntervalSeconds?: number | null; }; } diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts index e0377871c..4c8f5136a 100644 --- a/backend/src/ee/services/pam-account/pam-account-dal.ts +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -49,6 +49,7 @@ export const pamAccountDALFactory = (db: TDbClient) => { const accounts = await dbClient(TableName.PamAccount) .innerJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) .whereNotNull(`${TableName.PamResource}.encryptedRotationAccountCredentials`) + .whereNotNull(`${TableName.PamAccount}.rotationIntervalSeconds`) .whereRaw( `COALESCE("${TableName.PamAccount}"."lastRotatedAt", "${TableName.PamAccount}"."createdAt") + "${TableName.PamAccount}"."rotationIntervalSeconds" * interval '1 second' < NOW()` ) diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 8f4752f4e..7f6165d88 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -42,12 +42,12 @@ export const BaseCreatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }), description: z.string().max(512).nullable().optional(), rotationEnabled: z.boolean(), - rotationIntervalSeconds: z.number().min(3600) + rotationIntervalSeconds: z.number().min(3600).nullable().optional() }); export const BaseUpdatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }).optional(), description: z.string().max(512).nullable().optional(), rotationEnabled: z.boolean().optional(), - rotationIntervalSeconds: z.number().min(3600).optional() + rotationIntervalSeconds: z.number().min(3600).nullable().optional() }); diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 09ad6550d..20cb7aa60 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -14,7 +14,7 @@ export interface TBasePamAccount { name: string; description?: string | null; rotationEnabled: boolean; - rotationIntervalSeconds: number; + rotationIntervalSeconds?: number | null; lastRotatedAt?: string | null; createdAt: string; updatedAt: string; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx index 2739e1cd5..a73fad58b 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RotateAccountFields.tsx @@ -6,7 +6,7 @@ import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/component export const rotateAccountFieldsSchema = z.object({ rotationEnabled: z.boolean(), - rotationIntervalSeconds: z.number() + rotationIntervalSeconds: z.number().nullable().optional() }); export const RotateAccountFields = ({ @@ -16,7 +16,7 @@ export const RotateAccountFields = ({ }) => { const { control, watch } = useFormContext<{ rotationEnabled: boolean; - rotationIntervalSeconds: number; + rotationIntervalSeconds?: number | null; }>(); const rotationEnabled = watch("rotationEnabled"); @@ -62,7 +62,7 @@ export const RotateAccountFields = ({ className="mb-0" >