Specify the fields that the user must contain in their LDAP entry
in order to authenticate with this identity. If nothing is
@@ -466,7 +466,7 @@ export const IdentityLdapAuthForm = ({
The above example would allow users with the UID user1, user2, or
- user3 to authenticate. But only if their emails also match
+ user3 to authenticate but only if their emails also match
user@example.com
From a3b7df4e6b0ddd7176d5bd23d0f6717e8cf75cd7 Mon Sep 17 00:00:00 2001
From: Daniel Hougaard
Date: Thu, 8 May 2025 23:13:46 +0400
Subject: [PATCH 05/11] fix: addressed requested changes
---
backend/src/@types/fastify.d.ts | 2 +-
.../20250507003056_identity-ldap-auth.ts | 1 -
backend/src/db/schemas/identity-ldap-auths.ts | 1 -
.../ee/services/audit-log/audit-log-types.ts | 2 +-
backend/src/lib/api-docs/constants.ts | 43 +++++
.../routes/v1/identity-ldap-auth-router.ts | 93 ++++++-----
.../identity-ldap-auth-service.ts | 8 +-
.../identity-ldap-auth-types.ts | 2 -
.../src/hooks/api/identities/mutations.tsx | 4 -
frontend/src/hooks/api/identities/types.ts | 3 -
.../IdentitySection/IdentityLdapAuthForm.tsx | 150 ++++++++----------
.../ViewIdentityLdapAuthContent.tsx | 3 -
12 files changed, 166 insertions(+), 146 deletions(-)
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index 74571e2d0..748a7d431 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -152,7 +152,7 @@ declare module "fastify" {
identityId: string;
user: {
uid: string;
- mail: string;
+ mail?: string;
};
};
kmipUser: {
diff --git a/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts b/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts
index db7ba5281..da9912022 100644
--- a/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts
+++ b/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts
@@ -23,7 +23,6 @@ export async function up(knex: Knex): Promise {
t.string("url").notNullable();
t.string("searchBase").notNullable();
t.string("searchFilter").notNullable();
- t.string("uniqueAttribute").notNullable();
t.jsonb("allowedFields").nullable();
diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts
index e843648bc..d5b15fc6a 100644
--- a/backend/src/db/schemas/identity-ldap-auths.ts
+++ b/backend/src/db/schemas/identity-ldap-auths.ts
@@ -22,7 +22,6 @@ export const IdentityLdapAuthsSchema = z.object({
url: z.string(),
searchBase: z.string(),
searchFilter: z.string(),
- uniqueAttribute: z.string(),
allowedFields: z.unknown().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
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 92f4d54a7..a2caf2bff 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -1056,7 +1056,7 @@ interface LoginIdentityLdapAuthEvent {
metadata: {
identityId: string;
ldapUsername: string;
- ldapEmail: string;
+ ldapEmail?: string;
};
}
diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index 62ba2c974..7e82fe6f1 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -185,6 +185,49 @@ export const UNIVERSAL_AUTH = {
}
} as const;
+export const LDAP_AUTH = {
+ LOGIN: {
+ identityId: "The ID of the identity to login.",
+ username: "The username of the LDAP user to login.",
+ password: "The password of the LDAP user to login."
+ },
+ ATTACH: {
+ identityId: "The ID of the identity to attach the configuration onto.",
+ url: "The URL of the LDAP server.",
+ allowedFields:
+ "The comma-separated array of key/value pairs of required fields that the LDAP entry must have in order to authenticate.",
+ searchBase: "The base DN to search for the LDAP user.",
+ searchFilter: "The filter to use to search for the LDAP user.",
+ bindDN: "The DN of the user to bind to the LDAP server.",
+ bindPass: "The password of the user to bind to the LDAP server.",
+ ldapCaCertificate: "The PEM-encoded CA certificate for the LDAP server.",
+ accessTokenTTL: "The lifetime for an access token in seconds.",
+ accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.",
+ accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.",
+ accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from."
+ },
+ UPDATE: {
+ identityId: "The ID of the identity to update the configuration for.",
+ url: "The new URL of the LDAP server.",
+ allowedFields: "The comma-separated list of allowed fields to return from the LDAP user.",
+ searchBase: "The new base DN to search for the LDAP user.",
+ searchFilter: "The new filter to use to search for the LDAP user.",
+ bindDN: "The new DN of the user to bind to the LDAP server.",
+ bindPass: "The new password of the user to bind to the LDAP server.",
+ ldapCaCertificate: "The new PEM-encoded CA certificate for the LDAP server.",
+ accessTokenTTL: "The new lifetime for an access token in seconds.",
+ accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
+ accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.",
+ accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from."
+ },
+ RETRIEVE: {
+ identityId: "The ID of the identity to retrieve the configuration for."
+ },
+ REVOKE: {
+ identityId: "The ID of the identity to revoke the configuration for."
+ }
+} as const;
+
export const AWS_AUTH = {
LOGIN: {
identityId: "The ID of the identity to login.",
diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts
index bfc195f0a..ec3b64592 100644
--- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts
+++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts
@@ -15,7 +15,7 @@ import { z } from "zod";
import { IdentityLdapAuthsSchema } from "@app/db/schemas/identity-ldap-auths";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
-import { ApiDocsTags } from "@app/lib/api-docs";
+import { ApiDocsTags, LDAP_AUTH } from "@app/lib/api-docs";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
@@ -45,6 +45,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
...ldapConfig,
isActive: true,
groupSearchBase: "",
+ uniqueUserAttribute: "",
groupSearchFilter: ""
};
@@ -120,9 +121,9 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
tags: [ApiDocsTags.LdapAuth],
description: "Login with LDAP Auth",
body: z.object({
- identityId: z.string().trim(),
- username: z.string(),
- password: z.string()
+ identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId),
+ username: z.string().describe(LDAP_AUTH.LOGIN.username),
+ password: z.string().describe(LDAP_AUTH.LOGIN.password)
}),
response: {
200: z.object({
@@ -147,11 +148,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
},
handler: async (req) => {
- if (
- !req.passportMachineIdentity?.identityId ||
- !req.passportMachineIdentity.user.mail ||
- !req.passportMachineIdentity.user.uid
- ) {
+ if (!req.passportMachineIdentity?.identityId || !req.passportMachineIdentity.user.uid) {
throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." });
}
@@ -200,29 +197,40 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
}
],
params: z.object({
- identityId: z.string().trim()
+ identityId: z.string().trim().describe(LDAP_AUTH.ATTACH.identityId)
}),
body: z
.object({
- url: z.string().trim().min(1),
- bindDN: z.string().trim().min(1),
- bindPass: z.string().trim().min(1),
- searchBase: z.string().trim().min(1),
- uniqueAttribute: z.string().trim().min(1).default("uidNumber"),
- searchFilter: z.string().trim().min(1).default("(uid={{username}})"),
- allowedFields: AllowedFieldsSchema.array().optional(),
- ldapCaCertificate: z.string().trim().optional(),
-
+ url: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.url),
+ bindDN: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindDN),
+ bindPass: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindPass),
+ searchBase: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.searchBase),
+ searchFilter: z.string().trim().min(1).default("(uid={{username}})").describe(LDAP_AUTH.ATTACH.searchFilter),
+ allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields),
+ ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
- .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]),
- accessTokenTTL: z.number().int().min(0).max(315360000).default(2592000),
- accessTokenMaxTTL: z.number().int().min(1).max(315360000).default(2592000),
- accessTokenNumUsesLimit: z.number().int().min(0).default(0)
+ .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
+ .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps),
+ accessTokenTTL: z
+ .number()
+ .int()
+ .min(0)
+ .max(315360000)
+ .default(2592000)
+ .describe(LDAP_AUTH.ATTACH.accessTokenTTL),
+ accessTokenMaxTTL: z
+ .number()
+ .int()
+ .min(1)
+ .max(315360000)
+ .default(2592000)
+ .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL),
+ accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit)
})
.refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
@@ -286,27 +294,38 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
}
],
params: z.object({
- identityId: z.string()
+ identityId: z.string().trim().describe(LDAP_AUTH.UPDATE.identityId)
}),
body: z
.object({
- url: z.string().trim().min(1),
- bindDN: z.string().trim().min(1),
- bindPass: z.string().trim().min(1),
- searchBase: z.string().trim().min(1),
- uniqueAttribute: z.string().trim().min(1).default("uidNumber"),
- searchFilter: z.string().trim().min(1).default("(uid={{username}})"),
- allowedFields: AllowedFieldsSchema.array().optional(),
+ url: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.url),
+ bindDN: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.bindDN),
+ bindPass: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.bindPass),
+ searchBase: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.searchBase),
+ searchFilter: z.string().trim().min(1).default("(uid={{username}})").describe(LDAP_AUTH.UPDATE.searchFilter),
+ allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.UPDATE.allowedFields),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
- .optional(),
- accessTokenTTL: z.number().int().min(0).max(315360000).optional(),
- accessTokenNumUsesLimit: z.number().int().min(0).optional(),
- accessTokenMaxTTL: z.number().int().max(315360000).min(0).optional()
+ .optional()
+ .describe(LDAP_AUTH.UPDATE.accessTokenTrustedIps),
+ accessTokenTTL: z.number().int().min(0).max(315360000).optional().describe(LDAP_AUTH.UPDATE.accessTokenTTL),
+ accessTokenNumUsesLimit: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe(LDAP_AUTH.UPDATE.accessTokenNumUsesLimit),
+ accessTokenMaxTTL: z
+ .number()
+ .int()
+ .max(315360000)
+ .min(0)
+ .optional()
+ .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL)
})
.refine(
(val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
@@ -370,7 +389,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
}
],
params: z.object({
- identityId: z.string()
+ identityId: z.string().trim().describe(LDAP_AUTH.RETRIEVE.identityId)
}),
response: {
200: z.object({
@@ -427,7 +446,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
}
],
params: z.object({
- identityId: z.string()
+ identityId: z.string().trim().describe(LDAP_AUTH.REVOKE.identityId)
}),
response: {
200: z.object({
diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts
index 86343e3e2..7462c9228 100644
--- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts
+++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts
@@ -88,7 +88,6 @@ export const identityLdapAuthServiceFactory = ({
url: ldapAuth.url,
bindDN,
bindPass,
- uniqueUserAttribute: ldapAuth.uniqueAttribute,
searchBase: ldapAuth.searchBase,
searchFilter: ldapAuth.searchFilter,
caCert: ldapCaCertificate || "",
@@ -100,9 +99,8 @@ export const identityLdapAuthServiceFactory = ({
url: ldapAuth.url,
bindDN,
bindCredentials: bindPass,
- uniqueUserAttribute: ldapAuth.uniqueAttribute,
searchBase: ldapAuth.searchBase,
- searchFilter: ldapAuth.searchFilter || "(uid={{username}})",
+ searchFilter: ldapAuth.searchFilter,
...(ldapCaCertificate
? {
tlsOptions: {
@@ -178,7 +176,6 @@ export const identityLdapAuthServiceFactory = ({
url,
searchBase,
searchFilter,
- uniqueAttribute,
bindDN,
bindPass,
ldapCaCertificate,
@@ -287,7 +284,6 @@ export const identityLdapAuthServiceFactory = ({
encryptedBindPass,
searchBase,
searchFilter,
- uniqueAttribute,
url,
encryptedLdapCaCertificate,
accessTokenMaxTTL,
@@ -308,7 +304,6 @@ export const identityLdapAuthServiceFactory = ({
url,
searchBase,
searchFilter,
- uniqueAttribute,
bindDN,
bindPass,
ldapCaCertificate,
@@ -428,7 +423,6 @@ export const identityLdapAuthServiceFactory = ({
url,
searchBase,
searchFilter,
- uniqueAttribute,
encryptedBindDN,
encryptedBindPass,
encryptedLdapCaCertificate,
diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts
index cba6acbcb..0e6feb5fb 100644
--- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts
+++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts
@@ -17,7 +17,6 @@ export type TAttachLdapAuthDTO = {
url: string;
searchBase: string;
searchFilter: string;
- uniqueAttribute: string;
bindDN: string;
bindPass: string;
ldapCaCertificate?: string;
@@ -34,7 +33,6 @@ export type TUpdateLdapAuthDTO = {
url?: string;
searchBase?: string;
searchFilter?: string;
- uniqueAttribute?: string;
bindDN?: string;
bindPass?: string;
allowedFields?: TAllowedFields[];
diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx
index e19ec7458..e0077527f 100644
--- a/frontend/src/hooks/api/identities/mutations.tsx
+++ b/frontend/src/hooks/api/identities/mutations.tsx
@@ -1064,7 +1064,6 @@ export const useAddIdentityLdapAuth = () => {
bindPass,
searchBase,
searchFilter,
- uniqueAttribute,
ldapCaCertificate,
allowedFields,
accessTokenTTL,
@@ -1080,7 +1079,6 @@ export const useAddIdentityLdapAuth = () => {
bindPass,
searchBase,
searchFilter,
- uniqueAttribute,
ldapCaCertificate,
allowedFields,
accessTokenTTL,
@@ -1113,7 +1111,6 @@ export const useUpdateIdentityLdapAuth = () => {
bindPass,
searchBase,
searchFilter,
- uniqueAttribute,
ldapCaCertificate,
allowedFields,
accessTokenTTL,
@@ -1129,7 +1126,6 @@ export const useUpdateIdentityLdapAuth = () => {
bindPass,
searchBase,
searchFilter,
- uniqueAttribute,
ldapCaCertificate,
allowedFields,
accessTokenTTL,
diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts
index a97067e41..c5f8cbc4a 100644
--- a/frontend/src/hooks/api/identities/types.ts
+++ b/frontend/src/hooks/api/identities/types.ts
@@ -433,7 +433,6 @@ export type AddIdentityLdapAuthDTO = {
bindPass: string;
searchBase: string;
searchFilter: string;
- uniqueAttribute: string;
ldapCaCertificate?: string;
allowedFields?: {
key: string;
@@ -455,7 +454,6 @@ export type UpdateIdentityLdapAuthDTO = {
bindPass?: string;
searchBase?: string;
searchFilter?: string;
- uniqueAttribute?: string;
ldapCaCertificate?: string;
allowedFields?: {
key: string;
@@ -480,7 +478,6 @@ export type IdentityLdapAuth = {
bindPass: string;
searchBase: string;
searchFilter: string;
- uniqueAttribute: string;
ldapCaCertificate?: string;
allowedFields?: {
key: string;
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx
index 94658e0b5..e2dbc8b28 100644
--- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx
+++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx
@@ -35,7 +35,6 @@ const schema = z
bindDN: z.string(),
bindPass: z.string(),
searchBase: z.string(),
- uniqueAttribute: z.string(), // defaults to uidNumber
searchFilter: z.string(), // defaults to (uid={{username}})
ldapCaCertificate: z
.string()
@@ -112,7 +111,6 @@ export const IdentityLdapAuthForm = ({
bindDN: "",
bindPass: "",
searchBase: "",
- uniqueAttribute: "uidNumber",
searchFilter: "(uid={{username}})",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
@@ -140,7 +138,6 @@ export const IdentityLdapAuthForm = ({
bindDN: data.bindDN,
bindPass: data.bindPass,
searchBase: data.searchBase,
- uniqueAttribute: data.uniqueAttribute,
searchFilter: data.searchFilter,
ldapCaCertificate: data.ldapCaCertificate || undefined,
allowedFields: data.allowedFields,
@@ -161,7 +158,6 @@ export const IdentityLdapAuthForm = ({
bindDN: "",
bindPass: "",
searchBase: "",
- uniqueAttribute: "uidNumber",
searchFilter: "(uid={{username}})",
ldapCaCertificate: undefined,
allowedFields: [],
@@ -185,7 +181,6 @@ export const IdentityLdapAuthForm = ({
bindDN,
bindPass,
searchBase,
- uniqueAttribute,
searchFilter,
ldapCaCertificate,
allowedFields,
@@ -206,7 +201,6 @@ export const IdentityLdapAuthForm = ({
bindPass,
searchBase,
searchFilter,
- uniqueAttribute,
ldapCaCertificate,
allowedFields,
accessTokenTTL: Number(accessTokenTTL),
@@ -223,7 +217,6 @@ export const IdentityLdapAuthForm = ({
bindPass,
searchBase,
searchFilter,
- uniqueAttribute,
ldapCaCertificate,
allowedFields,
accessTokenTTL: Number(accessTokenTTL),
@@ -259,8 +252,8 @@ export const IdentityLdapAuthForm = ({
"bindPass",
"searchBase",
"searchFilter",
- "uniqueAttribute",
"accessTokenTTL",
+ "allowedFields",
"accessTokenMaxTTL",
"accessTokenNumUsesLimit"
].includes(Object.keys(fields)[0])
@@ -334,21 +327,6 @@ export const IdentityLdapAuthForm = ({
)}
/>
- (
-
-
-
- )}
- />
-
- (
-
-
-
- )}
- />
- (
-
-
-
- )}
- />
- (
-
-
-
- )}
- />
-
-
- (
-
-
-
- )}
- />
-
{allowedFieldsFields.map(({ id }, index) => (
From 64b8c1a2ded97c98b4fe785f9310c3a60fb9f15a Mon Sep 17 00:00:00 2001
From: Daniel Hougaard
Date: Thu, 8 May 2025 23:44:30 +0400
Subject: [PATCH 07/11] added filter check
---
backend/src/ee/services/ldap-config/ldap-fns.ts | 3 +--
.../routes/v1/identity-ldap-auth-router.ts | 17 +++++++++++++++--
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts
index ab23bdb45..01b70b4db 100644
--- a/backend/src/ee/services/ldap-config/ldap-fns.ts
+++ b/backend/src/ee/services/ldap-config/ldap-fns.ts
@@ -9,8 +9,7 @@ export const isValidLdapFilter = (filter: string) => {
ldapjs.parseFilter(filter);
return true;
} catch (error) {
- logger.error("Invalid LDAP filter");
- logger.error(error);
+ logger.error(error, "Invalid LDAP filter");
return false;
}
};
diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts
index 0d764425a..29c433287 100644
--- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts
+++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts
@@ -15,6 +15,7 @@ import { z } from "zod";
import { IdentityLdapAuthsSchema } from "@app/db/schemas/identity-ldap-auths";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { isValidLdapFilter } from "@app/ee/services/ldap-config/ldap-fns";
import { ApiDocsTags, LDAP_AUTH } from "@app/lib/api-docs";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
@@ -205,7 +206,13 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
bindDN: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindDN),
bindPass: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindPass),
searchBase: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.searchBase),
- searchFilter: z.string().trim().min(1).default("(uid={{username}})").describe(LDAP_AUTH.ATTACH.searchFilter),
+ searchFilter: z
+ .string()
+ .trim()
+ .min(1)
+ .default("(uid={{username}})")
+ .refine(isValidLdapFilter, "Invalid LDAP search filter")
+ .describe(LDAP_AUTH.ATTACH.searchFilter),
allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields),
ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate),
accessTokenTrustedIps: z
@@ -302,7 +309,13 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
bindDN: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.bindDN),
bindPass: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.bindPass),
searchBase: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.searchBase),
- searchFilter: z.string().trim().min(1).default("(uid={{username}})").describe(LDAP_AUTH.UPDATE.searchFilter),
+ searchFilter: z
+ .string()
+ .trim()
+ .min(1)
+ .default("(uid={{username}})")
+ .refine(isValidLdapFilter, "Invalid LDAP search filter")
+ .describe(LDAP_AUTH.UPDATE.searchFilter),
allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.UPDATE.allowedFields),
accessTokenTrustedIps: z
.object({
From c21873ac4b6983cfe93327f48125550b478a1c4b Mon Sep 17 00:00:00 2001
From: Daniel Hougaard
Date: Thu, 8 May 2025 23:48:08 +0400
Subject: [PATCH 08/11] Update identity-ldap-auth-router.ts
---
.../server/routes/v1/identity-ldap-auth-router.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts
index 29c433287..3da8a425b 100644
--- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts
+++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts
@@ -305,16 +305,16 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
}),
body: z
.object({
- url: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.url),
- bindDN: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.bindDN),
- bindPass: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.bindPass),
- searchBase: z.string().trim().min(1).describe(LDAP_AUTH.UPDATE.searchBase),
+ url: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.url),
+ bindDN: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindDN),
+ bindPass: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindPass),
+ searchBase: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.searchBase),
searchFilter: z
.string()
.trim()
.min(1)
- .default("(uid={{username}})")
- .refine(isValidLdapFilter, "Invalid LDAP search filter")
+ .optional()
+ .refine((v) => v === undefined || isValidLdapFilter(v), "Invalid LDAP search filter")
.describe(LDAP_AUTH.UPDATE.searchFilter),
allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.UPDATE.allowedFields),
accessTokenTrustedIps: z
From 6addde265012d695942339b51ea9e6330f12bf33 Mon Sep 17 00:00:00 2001
From: Daniel Hougaard
Date: Fri, 9 May 2025 03:44:15 +0400
Subject: [PATCH 09/11] docs(identities): ldap auth
---
.../endpoints/ldap-auth/attach.mdx | 4 +
.../endpoints/ldap-auth/login.mdx | 4 +
.../endpoints/ldap-auth/retrieve.mdx | 4 +
.../endpoints/ldap-auth/revoke.mdx | 4 +
.../endpoints/ldap-auth/update.mdx | 4 +
.../platform/identities/ldap-auth/general.mdx | 92 +++++++++++++
.../identities/ldap-auth/jumpcloud.mdx | 102 ++++++++++++++
.../identities-org-add-auth-method-modal.png | Bin 0 -> 204639 bytes
.../ldap/identities-org-add-auth-method.png | Bin 0 -> 279848 bytes
.../ldap/identities-org-configure-ldap.png | Bin 0 -> 273638 bytes
.../identities-org-create-identity-modal.png | Bin 0 -> 209011 bytes
.../ldap/identities-org-create-identity.png | Bin 0 -> 291634 bytes
.../ldap/jumpcloud-users-management.png | Bin 0 -> 389447 bytes
docs/mint.json | 130 +++++++++++-------
14 files changed, 294 insertions(+), 50 deletions(-)
create mode 100644 docs/api-reference/endpoints/ldap-auth/attach.mdx
create mode 100644 docs/api-reference/endpoints/ldap-auth/login.mdx
create mode 100644 docs/api-reference/endpoints/ldap-auth/retrieve.mdx
create mode 100644 docs/api-reference/endpoints/ldap-auth/revoke.mdx
create mode 100644 docs/api-reference/endpoints/ldap-auth/update.mdx
create mode 100644 docs/documentation/platform/identities/ldap-auth/general.mdx
create mode 100644 docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx
create mode 100644 docs/images/platform/identities/ldap/identities-org-add-auth-method-modal.png
create mode 100644 docs/images/platform/identities/ldap/identities-org-add-auth-method.png
create mode 100644 docs/images/platform/identities/ldap/identities-org-configure-ldap.png
create mode 100644 docs/images/platform/identities/ldap/identities-org-create-identity-modal.png
create mode 100644 docs/images/platform/identities/ldap/identities-org-create-identity.png
create mode 100644 docs/images/platform/identities/ldap/jumpcloud-users-management.png
diff --git a/docs/api-reference/endpoints/ldap-auth/attach.mdx b/docs/api-reference/endpoints/ldap-auth/attach.mdx
new file mode 100644
index 000000000..512878887
--- /dev/null
+++ b/docs/api-reference/endpoints/ldap-auth/attach.mdx
@@ -0,0 +1,4 @@
+---
+title: "Attach"
+openapi: "POST /api/v1/auth/ldap-auth/identities/{identityId}"
+---
diff --git a/docs/api-reference/endpoints/ldap-auth/login.mdx b/docs/api-reference/endpoints/ldap-auth/login.mdx
new file mode 100644
index 000000000..737afb857
--- /dev/null
+++ b/docs/api-reference/endpoints/ldap-auth/login.mdx
@@ -0,0 +1,4 @@
+---
+title: "Login"
+openapi: "POST /api/v1/auth/ldap-auth/login"
+---
diff --git a/docs/api-reference/endpoints/ldap-auth/retrieve.mdx b/docs/api-reference/endpoints/ldap-auth/retrieve.mdx
new file mode 100644
index 000000000..fe4974cde
--- /dev/null
+++ b/docs/api-reference/endpoints/ldap-auth/retrieve.mdx
@@ -0,0 +1,4 @@
+---
+title: "Retrieve"
+openapi: "GET /api/v1/auth/ldap-auth/identities/{identityId}"
+---
diff --git a/docs/api-reference/endpoints/ldap-auth/revoke.mdx b/docs/api-reference/endpoints/ldap-auth/revoke.mdx
new file mode 100644
index 000000000..2ef0996fd
--- /dev/null
+++ b/docs/api-reference/endpoints/ldap-auth/revoke.mdx
@@ -0,0 +1,4 @@
+---
+title: "Revoke"
+openapi: "DELETE /api/v1/auth/ldap-auth/identities/{identityId}"
+---
diff --git a/docs/api-reference/endpoints/ldap-auth/update.mdx b/docs/api-reference/endpoints/ldap-auth/update.mdx
new file mode 100644
index 000000000..74b54efd3
--- /dev/null
+++ b/docs/api-reference/endpoints/ldap-auth/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/auth/ldap-auth/identities/{identityId}"
+---
diff --git a/docs/documentation/platform/identities/ldap-auth/general.mdx b/docs/documentation/platform/identities/ldap-auth/general.mdx
new file mode 100644
index 000000000..d2d7e98bc
--- /dev/null
+++ b/docs/documentation/platform/identities/ldap-auth/general.mdx
@@ -0,0 +1,92 @@
+---
+title: General
+description: "Learn how to authenticate with Infisical using LDAP."
+---
+
+
+
+ LDAP is a paid feature. If you're using Infisical Cloud, then it is available under the Enterprise Tier. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it.
+
+
+**LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory.
+
+### Guide
+
+
+ To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**.
+
+ 
+
+ When creating an identity, you specify an organization level role for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles.
+
+ 
+
+ Now input a few details for your new identity. Here's some guidance for each field:
+
+ - Name (required): A friendly name for the identity.
+ - Role (required): A role from the Organization Roles tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to.
+
+ Once you've created an identity, you'll be redirected to a page where you can manage the identity.
+
+
+
+ To configure LDAP auth for your identity, press the **Add Auth Method** button on the identity's page.
+
+ 
+
+ Now select **LDAP Auth** from the list of available auth methods for the identity.
+
+ 
+
+
+ After selecting **LDAP Auth**, you'll see the form you need to fill out to configure LDAP auth for your identity. The following fields are available:
+
+ - `URL`: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` _(for connection over SSL/TLS)_, etc.
+ - `Bind DN`: The DN to bind to the LDAP server with.
+ - `Bind Pass`: The password to bind to the LDAP server with.
+ - `Search Base / DN`: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`.
+ - `User Search Filter`: Template used to construct the LDAP user search filter such as `(uid={{username}})`; use literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas.
+ - `Required Attributes`: A key/value pair of attributes that must be present in the LDAP user entry for them to be authenticated. As an example, if you set key `uid` to value `user1,user2,user3`, then only users with `uid` of `user1`, `user2`, or `user3` will be able to login with this identity. Each value is a comma separated list of attributes.
+ - `CA Certificate`: The CA certificate to use when verifying the LDAP server certificate. This field is optional but recommended.
+ - `Access Token TTL` _(default is 2592000 equivalent to 30 days)_: The lifetime for an access token in seconds. This value will be referenced at renewal time.
+ - `Access Token Max TTL` _(default is 2592000 equivalent to 30 days)_: The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.
+ - `Access Token Max Number of Uses` _(default is 0)_: The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.
+ - `Access Token Trusted IPs`: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the 0.0.0.0/0, allowing usage from any network address.
+
+ Once you've filled out the form, press **Add** to save your changes.
+
+ 
+
+
+ After configuring LDAP auth for your identity, you can authenticate with the identity and obtain an access token, using your LDAP credentials.
+
+ ```bash
+ curl --request POST \
+ --url https://app.infisical.com/api/v1/auth/ldap-auth/login \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "identityId": "",
+ "username": "",
+ "password": ""
+ }'
+ ```
+
+
+ For EU Cloud and Self-Hosted users, make sure to replace `https://app.infisical.com` with `https://eu.infisical.com` or your self-hosted instance's URL in the request URL.
+
+
+ If successful, you'll receive an access token in the response body.
+
+ ```json
+ {
+ "accessToken": "your-access-token",
+ "expiresIn": 2592000,
+ "accessTokenMaxTTL": 2592000,
+ "tokenType": "Bearer"
+ }
+ ```
+
+ You can read more about the login API endpoint [here](/api-reference/endpoints/ldap-auth/login).
+
+
+
\ No newline at end of file
diff --git a/docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx b/docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx
new file mode 100644
index 000000000..cb3a6f309
--- /dev/null
+++ b/docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx
@@ -0,0 +1,102 @@
+---
+title: JumpCloud
+description: "Learn how to authenticate with Infisical using LDAP with JumpCloud."
+---
+
+
+
+ LDAP is a paid feature. If you're using Infisical Cloud, then it is available under the Enterprise Tier. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it.
+
+
+**LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory.
+
+### Guide
+
+
+
+ In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the Manual user entry option.
+ This user will be used as a privileged service account to facilitate Infisical's ability to bind/search the LDAP directory.
+
+ Next after creating the user, under User Security Settings and Permissions > Permission Settings, check the box next to Enable as LDAP Bind DN.
+
+ 
+
+
+
+ To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**.
+
+ 
+
+ When creating an identity, you specify an organization level role for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles.
+
+ 
+
+ Now input a few details for your new identity. Here's some guidance for each field:
+
+ - Name (required): A friendly name for the identity.
+ - Role (required): A role from the Organization Roles tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to.
+
+ Once you've created an identity, you'll be redirected to a page where you can manage the identity.
+
+
+
+ To configure LDAP auth for your identity, press the **Add Auth Method** button on the identity's page.
+
+ 
+
+ Now select **LDAP Auth** from the list of available auth methods for the identity.
+
+ 
+
+
+ After selecting **LDAP Auth**, you'll see the form you need to fill out to configure LDAP auth for your identity. The following fields are available:
+
+ - `URL`: The LDAP server to connect to (`ldaps://ldap.jumpcloud.com:636`).
+ - `Bind DN`: The distinguished name of object to bind when performing the user search (`uid=,ou=Users,o=,dc=jumpcloud,dc=com`).
+ - `Bind Pass`: The password to use along with Bind DN when performing the user search. This is the password for the user created in the previous step.
+ - `Search Base / DN`: Base DN under which to perform user search (`ou=Users,o=,dc=jumpcloud,dc=com`).
+ - `User Search Filter`: Template used to construct the LDAP user search filter (`(uid={{username}})`).
+ - `Required Attributes`: A key/value pair of attributes that must be present in the LDAP user entry for them to be authenticated. As an example, if you set key `uid` to value `user1,user2,user3`, then only users with `uid` of `user1`, `user2`, or `user3` will be able to login with this identity. Each value is a comma separated list of attributes.
+ - `CA Certificate`: The CA certificate to use when verifying the LDAP server certificate (instructions to obtain the certificate for JumpCloud [here](https://jumpcloud.com/support/connect-to-ldap-with-tls-ssl)).
+ - `Access Token TTL` _(default is 2592000 equivalent to 30 days)_: The lifetime for an access token in seconds. This value will be referenced at renewal time.
+ - `Access Token Max TTL` _(default is 2592000 equivalent to 30 days)_: The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.
+ - `Access Token Max Number of Uses` _(default is 0)_: The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.
+ - `Access Token Trusted IPs`: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the 0.0.0.0/0, allowing usage from any network address.
+
+ Once you've filled out the form, press **Add** to save your changes.
+
+ 
+
+
+ After configuring LDAP auth for your identity, you can authenticate with the identity and obtain an access token, using your LDAP credentials.
+
+ ```bash
+ curl --request POST \
+ --url https://app.infisical.com/api/v1/auth/ldap-auth/login \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "identityId": "",
+ "username": "",
+ "password": ""
+ }'
+ ```
+
+
+ For EU Cloud and Self-Hosted users, make sure to replace `https://app.infisical.com` with `https://eu.infisical.com` or your self-hosted instance's URL in the request URL.
+
+
+ If successful, you'll receive an access token in the response body.
+
+ ```json
+ {
+ "accessToken": "your-access-token",
+ "expiresIn": 2592000,
+ "accessTokenMaxTTL": 2592000,
+ "tokenType": "Bearer"
+ }
+ ```
+
+ You can read more about the login API endpoint [here](/api-reference/endpoints/ldap-auth/login).
+
+
+
\ No newline at end of file
diff --git a/docs/images/platform/identities/ldap/identities-org-add-auth-method-modal.png b/docs/images/platform/identities/ldap/identities-org-add-auth-method-modal.png
new file mode 100644
index 0000000000000000000000000000000000000000..e9a5f276c7d6359249ded2c611328288ae961a60
GIT binary patch
literal 204639
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