feature: ldap connection and password rotation

This commit is contained in:
Scott Wilson
2025-04-18 17:55:03 -07:00
parent f1b1d6f480
commit 9032bbe514
92 changed files with 1980 additions and 90 deletions
@@ -0,0 +1,3 @@
export * from "./ldap-password-rotation-constants";
export * from "./ldap-password-rotation-schemas";
export * from "./ldap-password-rotation-types";
@@ -0,0 +1,15 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const LDAP_PASSWORD_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
name: "LDAP Password",
type: SecretRotation.LdapPassword,
connection: AppConnection.Ldap,
template: {
secretsMapping: {
dn: "LDAP_DN",
password: "LDAP_PASSWORD"
}
}
};
@@ -0,0 +1,181 @@
import ldap from "ldapjs";
import {
TRotationFactory,
TRotationFactoryGetSecretsPayload,
TRotationFactoryIssueCredentials,
TRotationFactoryRevokeCredentials,
TRotationFactoryRotateCredentials
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { logger } from "@app/lib/logger";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap";
import { generatePassword } from "../shared/utils";
import {
TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationWithConnection
} from "./ldap-password-rotation-types";
const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le");
export const ldapPasswordRotationFactory: TRotationFactory<
TLdapPasswordRotationWithConnection,
TLdapPasswordRotationGeneratedCredentials
> = (secretRotation, appConnectionDAL, kmsService) => {
const {
connection,
parameters: { dn, passwordRequirements },
secretsMapping
} = secretRotation;
const $verifyCredentials = async (credentials: Pick<TLdapConnection["credentials"], "dn" | "password">) => {
try {
const client = await getLdapConnectionClient({ ...connection.credentials, ...credentials });
client.unbind();
client.destroy();
} catch (error) {
throw new Error(`Failed to verify credentials - ${(error as Error).message}`);
}
};
const $rotatePassword = async () => {
const { credentials, orgId } = connection;
if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection");
const client = await getLdapConnectionClient(credentials);
const isPersonalRotation = credentials.dn === dn;
const password = generatePassword(passwordRequirements);
let changes: ldap.Change[] | ldap.Change;
switch (credentials.provider) {
case LdapProvider.ActiveDirectory:
{
const encodedPassword = getEncodedPassword(password);
// service account vs personal password rotation require different changes
if (isPersonalRotation) {
const currentEncodedPassword = getEncodedPassword(credentials.password);
changes = [
new ldap.Change({
operation: "delete",
modification: {
type: "unicodePwd",
values: [currentEncodedPassword]
}
}),
new ldap.Change({
operation: "add",
modification: {
type: "unicodePwd",
values: [encodedPassword]
}
})
];
} else {
changes = new ldap.Change({
operation: "replace",
modification: {
type: "unicodePwd",
values: [encodedPassword]
}
});
}
}
break;
default:
throw new Error(`Unhandled provider: ${credentials.provider as LdapProvider}`);
}
try {
await new Promise((resolve, reject) => {
client.modify(dn, changes, (err) => {
if (err) {
logger.error(err, "LDAP Password Rotation Failed");
reject(new Error(`Provider Modify Error: ${err.message}`));
} else {
resolve(true);
}
});
});
} finally {
client.unbind();
client.destroy();
}
await $verifyCredentials({ dn, password });
if (isPersonalRotation) {
const updatedCredentials: TLdapConnection["credentials"] = {
...credentials,
password
};
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
});
await appConnectionDAL.updateById(connection.id, { encryptedCredentials });
}
return { dn, password };
};
const issueCredentials: TRotationFactoryIssueCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
callback
) => {
const credentials = await $rotatePassword();
return callback(credentials);
};
const revokeCredentials: TRotationFactoryRevokeCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
_,
callback
) => {
// we just rotate to a new password, essentially revoking old credentials
await $rotatePassword();
return callback();
};
const rotateCredentials: TRotationFactoryRotateCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
_,
callback
) => {
const credentials = await $rotatePassword();
return callback(credentials);
};
const getSecretsPayload: TRotationFactoryGetSecretsPayload<TLdapPasswordRotationGeneratedCredentials> = (
generatedCredentials
) => {
const secrets = [
{
key: secretsMapping.dn,
value: generatedCredentials.dn
},
{
key: secretsMapping.password,
value: generatedCredentials.password
}
];
return secrets;
};
return {
issueCredentials,
revokeCredentials,
rotateCredentials,
getSecretsPayload
};
};
@@ -0,0 +1,65 @@
import { z } from "zod";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
BaseCreateSecretRotationSchema,
BaseSecretRotationSchema,
BaseUpdateSecretRotationSchema
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general";
import { SecretRotations } from "@app/lib/api-docs";
import { SecretNameSchema } from "@app/server/lib/schemas";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const LdapPasswordRotationGeneratedCredentialsSchema = z
.object({
dn: z.string(),
password: z.string()
})
.array()
.min(1)
.max(2);
const LdapPasswordRotationParametersSchema = z.object({
dn: z
.string()
.trim()
.min(1, "Distinguished Name (DN) Required")
.describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn),
passwordRequirements: PasswordRequirementsSchema.optional()
});
const LdapPasswordRotationSecretsMappingSchema = z.object({
dn: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.LDAP_PASSWORD.dn),
password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.LDAP_PASSWORD.password)
});
export const LdapPasswordRotationTemplateSchema = z.object({
secretsMapping: z.object({
dn: z.string(),
password: z.string()
})
});
export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotation.LdapPassword).extend({
type: z.literal(SecretRotation.LdapPassword),
parameters: LdapPasswordRotationParametersSchema,
secretsMapping: LdapPasswordRotationSecretsMappingSchema
});
export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({
parameters: LdapPasswordRotationParametersSchema,
secretsMapping: LdapPasswordRotationSecretsMappingSchema
});
export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({
parameters: LdapPasswordRotationParametersSchema.optional(),
secretsMapping: LdapPasswordRotationSecretsMappingSchema.optional()
});
export const LdapPasswordRotationListItemSchema = z.object({
name: z.literal("LDAP Password"),
connection: z.literal(AppConnection.Ldap),
type: z.literal(SecretRotation.LdapPassword),
template: LdapPasswordRotationTemplateSchema
});
@@ -0,0 +1,22 @@
import { z } from "zod";
import { TLdapConnection } from "@app/services/app-connection/ldap";
import {
CreateLdapPasswordRotationSchema,
LdapPasswordRotationGeneratedCredentialsSchema,
LdapPasswordRotationListItemSchema,
LdapPasswordRotationSchema
} from "./ldap-password-rotation-schemas";
export type TLdapPasswordRotation = z.infer<typeof LdapPasswordRotationSchema>;
export type TLdapPasswordRotationInput = z.infer<typeof CreateLdapPasswordRotationSchema>;
export type TLdapPasswordRotationListItem = z.infer<typeof LdapPasswordRotationListItemSchema>;
export type TLdapPasswordRotationWithConnection = TLdapPasswordRotation & {
connection: TLdapConnection;
};
export type TLdapPasswordRotationGeneratedCredentials = z.infer<typeof LdapPasswordRotationGeneratedCredentialsSchema>;
@@ -1,7 +1,8 @@
export enum SecretRotation {
PostgresCredentials = "postgres-credentials",
MsSqlCredentials = "mssql-credentials",
Auth0ClientSecret = "auth0-client-secret"
Auth0ClientSecret = "auth0-client-secret",
LdapPassword = "ldap-password"
}
export enum SecretRotationStatus {
@@ -4,6 +4,7 @@ import { getConfig } from "@app/lib/config/env";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password";
import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
@@ -18,7 +19,8 @@ import {
const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = {
[SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION,
[SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION,
[SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION
[SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION,
[SecretRotation.LdapPassword]: LDAP_PASSWORD_ROTATION_LIST_OPTION
};
export const listSecretRotationOptions = () => {
@@ -4,11 +4,13 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
export const SECRET_ROTATION_NAME_MAP: Record<SecretRotation, string> = {
[SecretRotation.PostgresCredentials]: "PostgreSQL Credentials",
[SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials",
[SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret"
[SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret",
[SecretRotation.LdapPassword]: "LDAP Password"
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
[SecretRotation.PostgresCredentials]: AppConnection.Postgres,
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql,
[SecretRotation.Auth0ClientSecret]: AppConnection.Auth0
[SecretRotation.Auth0ClientSecret]: AppConnection.Auth0,
[SecretRotation.LdapPassword]: AppConnection.Ldap
};
@@ -14,6 +14,7 @@ import {
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns";
import { ldapPasswordRotationFactory } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns";
import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
calculateNextRotationAt,
@@ -114,7 +115,8 @@ type TRotationFactoryImplementation = TRotationFactory<
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = {
[SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
[SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
[SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation
[SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation,
[SecretRotation.LdapPassword]: ldapPasswordRotationFactory as TRotationFactoryImplementation
};
export const secretRotationV2ServiceFactory = ({
@@ -449,6 +451,18 @@ export const secretRotationV2ServiceFactory = ({
kmsService
);
// even though we have a db constraint we want to check before any rotation of credentials is attempted
// to prevent creation failure after external credentials have been modified
const conflictingRotation = await secretRotationV2DAL.findOne({
name: payload.name,
folderId: folder.id
});
if (conflictingRotation)
throw new BadRequestError({
message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"`
});
try {
const currentTime = new Date();
@@ -12,6 +12,13 @@ import {
TAuth0ClientSecretRotationListItem,
TAuth0ClientSecretRotationWithConnection
} from "./auth0-client-secret";
import {
TLdapPasswordRotation,
TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput,
TLdapPasswordRotationListItem,
TLdapPasswordRotationWithConnection
} from "./ldap-password";
import {
TMsSqlCredentialsRotation,
TMsSqlCredentialsRotationInput,
@@ -27,26 +34,34 @@ import {
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation } from "./secret-rotation-v2-enums";
export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation;
export type TSecretRotationV2 =
| TPostgresCredentialsRotation
| TMsSqlCredentialsRotation
| TAuth0ClientSecretRotation
| TLdapPasswordRotation;
export type TSecretRotationV2WithConnection =
| TPostgresCredentialsRotationWithConnection
| TMsSqlCredentialsRotationWithConnection
| TAuth0ClientSecretRotationWithConnection;
| TAuth0ClientSecretRotationWithConnection
| TLdapPasswordRotationWithConnection;
export type TSecretRotationV2GeneratedCredentials =
| TSqlCredentialsRotationGeneratedCredentials
| TAuth0ClientSecretRotationGeneratedCredentials;
| TAuth0ClientSecretRotationGeneratedCredentials
| TLdapPasswordRotationGeneratedCredentials;
export type TSecretRotationV2Input =
| TPostgresCredentialsRotationInput
| TMsSqlCredentialsRotationInput
| TAuth0ClientSecretRotationInput;
| TAuth0ClientSecretRotationInput
| TLdapPasswordRotationInput;
export type TSecretRotationV2ListItem =
| TPostgresCredentialsRotationListItem
| TMsSqlCredentialsRotationListItem
| TAuth0ClientSecretRotationListItem;
| TAuth0ClientSecretRotationListItem
| TLdapPasswordRotationListItem;
export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>;
@@ -1,11 +1,13 @@
import { z } from "zod";
import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret";
import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password";
import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
export const SecretRotationV2Schema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema,
Auth0ClientSecretRotationSchema
Auth0ClientSecretRotationSchema,
LdapPasswordRotationSchema
]);
@@ -0,0 +1 @@
export * from "./password-requirements-schema";
@@ -0,0 +1,39 @@
import { z } from "zod";
import { SecretRotations } from "@app/lib/api-docs";
export const PasswordRequirementsSchema = z
.object({
length: z
.number()
.min(1, "Password length must be a positive number")
.max(250, "Password length must be less than 250")
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.length),
required: z.object({
digits: z
.number()
.min(0, "Digit count must be non-negative")
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.digits),
lowercase: z
.number()
.min(0, "Lowercase count must be non-negative")
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.lowercase),
uppercase: z
.number()
.min(0, "Uppercase count must be non-negative")
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.uppercase),
symbols: z
.number()
.min(0, "Symbol count must be non-negative")
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.symbols)
}),
allowedSymbols: z
.string()
.optional()
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.allowedSymbols)
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.base);
@@ -1,6 +1,17 @@
import { randomInt } from "crypto";
const DEFAULT_PASSWORD_REQUIREMENTS = {
type TPasswordRequirements = {
length: number;
required: {
lowercase: number;
uppercase: number;
digits: number;
symbols: number;
};
allowedSymbols?: string;
};
const DEFAULT_PASSWORD_REQUIREMENTS: TPasswordRequirements = {
length: 48,
required: {
lowercase: 1,
@@ -11,9 +22,9 @@ const DEFAULT_PASSWORD_REQUIREMENTS = {
allowedSymbols: "-_.~!*"
};
export const generatePassword = () => {
export const generatePassword = (passwordRequirements?: TPasswordRequirements) => {
try {
const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS;
const { length, required, allowedSymbols } = passwordRequirements ?? DEFAULT_PASSWORD_REQUIREMENTS;
const chars = {
lowercase: "abcdefghijklmnopqrstuvwxyz",