feat(pam): account credential rotation

This commit is contained in:
x032205
2025-10-16 18:12:03 -04:00
parent 4092325a02
commit f0edcd1f0f
25 changed files with 588 additions and 36 deletions
@@ -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;
};
@@ -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()
});
@@ -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);
@@ -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<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
@@ -30,6 +30,10 @@ export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnecti
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
credentials: C
) => Promise<C>;
export type TPamResourceFactoryRotateAccountCredentials<C extends TPamAccountCredentials> = (
rotationAccountCredentials: C,
currentCredentials: C
) => Promise<C>;
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
resourceType: PamResource,
@@ -39,4 +43,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
) => {
validateConnection: TPamResourceFactoryValidateConnection<T>;
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
};
@@ -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
});
@@ -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<TSqlResourceConnectionDetai
}
};
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TSqlAccountCredentials> = 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
};
};