diff --git a/changelog/fragments/1764010000-translate-ldap-guid-inference.yaml b/changelog/fragments/1764010000-translate-ldap-guid-inference.yaml
new file mode 100644
index 000000000000..10aabe317052
--- /dev/null
+++ b/changelog/fragments/1764010000-translate-ldap-guid-inference.yaml
@@ -0,0 +1,45 @@
+# REQUIRED
+# Kind can be one of:
+# - breaking-change: a change to previously-documented behavior
+# - deprecation: functionality that is being removed in a later release
+# - bug-fix: fixes a problem in a previous version
+# - enhancement: extends functionality but does not break or fix existing behavior
+# - feature: new functionality
+# - known-issue: problems that we are aware of in a given version
+# - security: impacts on the security of a product or a user’s deployment.
+# - upgrade: important information for someone upgrading from a prior version
+# - other: does not fit into any of the other categories
+kind: enhancement
+
+# REQUIRED for all kinds
+# Change summary; a 80ish characters long description of the change.
+summary: Add GUID translation, base DN inference, and SSPI authentication to LDAP processor.
+
+# REQUIRED for breaking-change, deprecation, known-issue
+# Long description; in case the summary is not enough to describe the change
+# this field accommodate a description without length limits.
+# description:
+
+# REQUIRED for breaking-change, deprecation, known-issue
+# impact:
+
+# REQUIRED for breaking-change, deprecation, known-issue
+# action:
+
+# REQUIRED for all kinds
+# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
+component: all
+
+# AUTOMATED
+# OPTIONAL to manually add other PR URLs
+# PR URL: A link the PR that added the changeset.
+# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
+# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
+# Please provide it if you are adding a fragment for a different PR.
+pr: https://github.com/elastic/beats/pull/47827
+
+# AUTOMATED
+# OPTIONAL to manually add other issue URLs
+# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
+# If not present is automatically filled by the tooling with the issue linked to the PR number.
+# issue: https://github.com/owner/repo/1234
diff --git a/docs/reference/auditbeat/processor-translate-guid.md b/docs/reference/auditbeat/processor-translate-guid.md
index d5a4514f4e86..420749ed35cf 100644
--- a/docs/reference/auditbeat/processor-translate-guid.md
+++ b/docs/reference/auditbeat/processor-translate-guid.md
@@ -6,27 +6,27 @@ applies_to:
stack: ga
---
-# Translate GUID [processor-translate-guid]
+# Translate LDAP Attribute [processor-translate-ldap-attribute]
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`).
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names.
+Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs.
-Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs.
-
-If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set.
+If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event.
+The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned.
```yaml
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
```
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting
| --- | --- | --- | --- |
| `field` | yes | | Source field containing a GUID. |
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. |
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` |
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` |
-| `ldap_bind_user` | no | | LDAP user. |
-| `ldap_bind_password` | no | | LDAP password. |
+| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). |
+| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. |
+| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. |
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). |
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. |
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. |
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. |
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. |
-| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. |
+| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/auditbeat/configuration-ssl.md). |
+| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. |
| `ignore_missing` | no | false | Ignore errors when the source field is missing. |
| `ignore_failure` | no | false | Ignore all errors produced by the processor. |
-\* Also see [SSL](/reference/auditbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options.
+## Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication.
+3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+## Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing:
@@ -78,4 +100,3 @@ processors:
key_field: winlog.event_data.ObjectGuid
value_field: winlog.common_name
```
-
diff --git a/docs/reference/filebeat/processor-translate-guid.md b/docs/reference/filebeat/processor-translate-guid.md
index 89af9d4c963b..ebd7cf6165b3 100644
--- a/docs/reference/filebeat/processor-translate-guid.md
+++ b/docs/reference/filebeat/processor-translate-guid.md
@@ -6,27 +6,27 @@ applies_to:
stack: ga
---
-# Translate GUID [processor-translate-guid]
+# Translate LDAP Attribute [processor-translate-ldap-attribute]
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`).
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names.
+Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs.
-Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs.
-
-If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set.
+If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event.
+The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned.
```yaml
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
```
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting
| --- | --- | --- | --- |
| `field` | yes | | Source field containing a GUID. |
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. |
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` |
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` |
-| `ldap_bind_user` | no | | LDAP user. |
-| `ldap_bind_password` | no | | LDAP password. |
+| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). |
+| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. |
+| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. |
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). |
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. |
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. |
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. |
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. |
-| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. |
+| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/filebeat/configuration-ssl.md). |
+| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. |
| `ignore_missing` | no | false | Ignore errors when the source field is missing. |
| `ignore_failure` | no | false | Ignore all errors produced by the processor. |
-\* Also see [SSL](/reference/filebeat/configuration-ssl.md) for a full description of the `ldap_ssl` options.
+## Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication.
+3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+## Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing:
@@ -78,4 +100,3 @@ processors:
key_field: winlog.event_data.ObjectGuid
value_field: winlog.common_name
```
-
diff --git a/docs/reference/heartbeat/processor-translate-guid.md b/docs/reference/heartbeat/processor-translate-guid.md
index 5984145c89f3..50d9527b719c 100644
--- a/docs/reference/heartbeat/processor-translate-guid.md
+++ b/docs/reference/heartbeat/processor-translate-guid.md
@@ -6,27 +6,27 @@ applies_to:
stack: ga
---
-# Translate GUID [processor-translate-guid]
+# Translate LDAP Attribute [processor-translate-ldap-attribute]
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`).
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names.
+Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs.
-Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs.
-
-If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set.
+If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event.
+The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned.
```yaml
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
```
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting
| --- | --- | --- | --- |
| `field` | yes | | Source field containing a GUID. |
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. |
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` |
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` |
-| `ldap_bind_user` | no | | LDAP user. |
-| `ldap_bind_password` | no | | LDAP password. |
+| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). |
+| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. |
+| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. |
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). |
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. |
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. |
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. |
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. |
-| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. |
+| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/heartbeat/configuration-ssl.md). |
+| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. |
| `ignore_missing` | no | false | Ignore errors when the source field is missing. |
| `ignore_failure` | no | false | Ignore all errors produced by the processor. |
-\* Also see [SSL](/reference/heartbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options.
+## Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication.
+3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+## Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing:
@@ -78,4 +100,3 @@ processors:
key_field: winlog.event_data.ObjectGuid
value_field: winlog.common_name
```
-
diff --git a/docs/reference/metricbeat/processor-translate-guid.md b/docs/reference/metricbeat/processor-translate-guid.md
index 6eef946fe5d2..63e0427f84a0 100644
--- a/docs/reference/metricbeat/processor-translate-guid.md
+++ b/docs/reference/metricbeat/processor-translate-guid.md
@@ -6,27 +6,27 @@ applies_to:
stack: ga
---
-# Translate GUID [processor-translate-guid]
+# Translate LDAP Attribute [processor-translate-ldap-attribute]
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`).
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names.
+Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs.
-Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs.
-
-If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set.
+If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event.
+The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned.
```yaml
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
```
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting
| --- | --- | --- | --- |
| `field` | yes | | Source field containing a GUID. |
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. |
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` |
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` |
-| `ldap_bind_user` | no | | LDAP user. |
-| `ldap_bind_password` | no | | LDAP password. |
+| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). |
+| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. |
+| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. |
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). |
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. |
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. |
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. |
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. |
-| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. |
+| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/metricbeat/configuration-ssl.md). |
+| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. |
| `ignore_missing` | no | false | Ignore errors when the source field is missing. |
| `ignore_failure` | no | false | Ignore all errors produced by the processor. |
-\* Also see [SSL](/reference/metricbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options.
+## Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication.
+3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+## Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing:
@@ -78,4 +100,3 @@ processors:
key_field: winlog.event_data.ObjectGuid
value_field: winlog.common_name
```
-
diff --git a/docs/reference/packetbeat/processor-translate-guid.md b/docs/reference/packetbeat/processor-translate-guid.md
index 6ea74e97af5b..92e7e39ce8fd 100644
--- a/docs/reference/packetbeat/processor-translate-guid.md
+++ b/docs/reference/packetbeat/processor-translate-guid.md
@@ -6,27 +6,27 @@ applies_to:
stack: ga
---
-# Translate GUID [processor-translate-guid]
+# Translate LDAP Attribute [processor-translate-ldap-attribute]
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`).
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names.
+Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs.
-Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs.
-
-If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set.
+If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event.
+The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned.
```yaml
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
```
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting
| --- | --- | --- | --- |
| `field` | yes | | Source field containing a GUID. |
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. |
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` |
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` |
-| `ldap_bind_user` | no | | LDAP user. |
-| `ldap_bind_password` | no | | LDAP password. |
+| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). |
+| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. |
+| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. |
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). |
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. |
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. |
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. |
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. |
-| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. |
+| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/packetbeat/configuration-ssl.md). |
+| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. |
| `ignore_missing` | no | false | Ignore errors when the source field is missing. |
| `ignore_failure` | no | false | Ignore all errors produced by the processor. |
-\* Also see [SSL](/reference/packetbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options.
+## Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication.
+3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+## Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing:
@@ -78,4 +100,3 @@ processors:
key_field: winlog.event_data.ObjectGuid
value_field: winlog.common_name
```
-
diff --git a/docs/reference/winlogbeat/processor-translate-guid.md b/docs/reference/winlogbeat/processor-translate-guid.md
index fe7c1fc483eb..261939ca7668 100644
--- a/docs/reference/winlogbeat/processor-translate-guid.md
+++ b/docs/reference/winlogbeat/processor-translate-guid.md
@@ -6,27 +6,27 @@ applies_to:
stack: ga
---
-# Translate GUID [processor-translate-guid]
+# Translate LDAP Attribute [processor-translate-ldap-attribute]
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`).
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names.
+Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs.
-Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs.
-
-If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set.
+If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event.
+The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned.
```yaml
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
```
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting
| --- | --- | --- | --- |
| `field` | yes | | Source field containing a GUID. |
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. |
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` |
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` |
-| `ldap_bind_user` | no | | LDAP user. |
-| `ldap_bind_password` | no | | LDAP password. |
+| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). |
+| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. |
+| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. |
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). |
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. |
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. |
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. |
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. |
-| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. |
+| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/winlogbeat/configuration-ssl.md). |
+| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. |
| `ignore_missing` | no | false | Ignore errors when the source field is missing. |
| `ignore_failure` | no | false | Ignore all errors produced by the processor. |
-\* Also see [SSL](/reference/winlogbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options.
+## Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication.
+3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+## Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing:
@@ -78,4 +100,3 @@ processors:
key_field: winlog.event_data.ObjectGuid
value_field: winlog.common_name
```
-
diff --git a/go.mod b/go.mod
index e76588d21136..f8d97241f4c8 100644
--- a/go.mod
+++ b/go.mod
@@ -286,6 +286,7 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/VictoriaMetrics/easyproto v0.1.4 // indirect
+ github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow/go/v15 v15.0.2 // indirect
diff --git a/libbeat/processors/translate_ldap_attribute/config.go b/libbeat/processors/translate_ldap_attribute/config.go
index 4a669f582de2..ce59e5b4da02 100644
--- a/libbeat/processors/translate_ldap_attribute/config.go
+++ b/libbeat/processors/translate_ldap_attribute/config.go
@@ -20,14 +20,24 @@
package translate_ldap_attribute
import (
+ "fmt"
+ "strings"
+
"github.com/elastic/elastic-agent-libs/transport/tlscommon"
)
+const (
+ guidTranslationAuto = "auto"
+ guidTranslationAlways = "always"
+ guidTranslationNever = "never"
+)
+
type config struct {
Field string `config:"field" validate:"required"`
TargetField string `config:"target_field"`
- LDAPAddress string `config:"ldap_address" validate:"required"`
- LDAPBaseDN string `config:"ldap_base_dn" validate:"required"`
+ LDAPDomain string `config:"ldap_domain"`
+ LDAPAddress string `config:"ldap_address"`
+ LDAPBaseDN string `config:"ldap_base_dn"`
LDAPBindUser string `config:"ldap_bind_user"`
LDAPBindPassword string `config:"ldap_bind_password"`
LDAPSearchAttribute string `config:"ldap_search_attribute" validate:"required"`
@@ -35,6 +45,16 @@ type config struct {
LDAPSearchTimeLimit int `config:"ldap_search_time_limit"`
LDAPTLS *tlscommon.Config `config:"ldap_ssl"`
+ // ADGUIDTranslation controls when GUID values get converted to the binary form
+ // expected by Active Directory. We no longer rely on server detection; the
+ // auto mode simply checks whether the configured search attribute is named
+ // objectGUID (case-insensitive).
+ // Supported values:
+ // "auto" (default): Convert when LDAP search attribute equals objectGUID
+ // "always": Always apply GUID conversion regardless of attribute name
+ // "never" : Never apply GUID conversion
+ ADGUIDTranslation string `config:"ad_guid_translation"`
+
IgnoreMissing bool `config:"ignore_missing"`
IgnoreFailure bool `config:"ignore_failure"`
}
@@ -43,5 +63,21 @@ func defaultConfig() config {
return config{
LDAPSearchAttribute: "objectGUID",
LDAPMappedAttribute: "cn",
- LDAPSearchTimeLimit: 30}
+ LDAPSearchTimeLimit: 30,
+ ADGUIDTranslation: guidTranslationAuto,
+ }
+}
+
+func (c *config) validate() error {
+ switch strings.ToLower(strings.TrimSpace(c.ADGUIDTranslation)) {
+ case "", guidTranslationAuto:
+ c.ADGUIDTranslation = guidTranslationAuto
+ case guidTranslationAlways:
+ c.ADGUIDTranslation = guidTranslationAlways
+ case guidTranslationNever:
+ c.ADGUIDTranslation = guidTranslationNever
+ default:
+ return fmt.Errorf("invalid ad_guid_translation value %q (expected auto|always|never)", c.ADGUIDTranslation)
+ }
+ return nil
}
diff --git a/libbeat/processors/translate_ldap_attribute/config_test.go b/libbeat/processors/translate_ldap_attribute/config_test.go
new file mode 100644
index 000000000000..40be593ed042
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/config_test.go
@@ -0,0 +1,57 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !requirefips
+
+package translate_ldap_attribute
+
+import "testing"
+
+func TestConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ expect string
+ expectError bool
+ }{
+ {name: "empty defaults to auto", value: "", expect: guidTranslationAuto},
+ {name: "explicit auto", value: "auto", expect: guidTranslationAuto},
+ {name: "explicit always", value: "always", expect: guidTranslationAlways},
+ {name: "case insensitive", value: " NEVER ", expect: guidTranslationNever},
+ {name: "invalid", value: "sometimes", expectError: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.ADGUIDTranslation = tt.value
+ err := cfg.validate()
+ if tt.expectError {
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.ADGUIDTranslation != tt.expect {
+ t.Fatalf("expected %q, got %q", tt.expect, cfg.ADGUIDTranslation)
+ }
+ })
+ }
+}
diff --git a/libbeat/processors/translate_ldap_attribute/discovery.go b/libbeat/processors/translate_ldap_attribute/discovery.go
new file mode 100644
index 000000000000..0244101b9086
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/discovery.go
@@ -0,0 +1,180 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "runtime"
+ "slices"
+ "strings"
+
+ "github.com/elastic/elastic-agent-libs/logp"
+)
+
+var (
+ // errNoLDAPServerFound is returned when no LDAP server can be discovered
+ errNoLDAPServerFound = errors.New("no LDAP server found via DNS SRV or system configuration")
+)
+
+// discoverLDAPAddress attempts to auto-discover the LDAP server address.
+// It returns a list of candidate addresses sorted by preference (LDAPS over LDAP, SRV over LOGONSERVER).
+// The caller should attempt to connect to each address in order until one succeeds.
+func discoverLDAPAddress(configDomain string, log *logp.Logger) ([]string, error) {
+ log.Debug("attempting LDAP server auto-discovery")
+
+ domain := discoverDomain(configDomain, log)
+
+ var candidates []string
+
+ if domain != "" {
+ // 1. Primary: DNS SRV Lookup (LDAPS, then LDAP)
+ candidates = append(candidates, lookupSRVServers(domain, true, log)...)
+ candidates = append(candidates, lookupSRVServers(domain, false, log)...)
+ }
+
+ if len(candidates) > 0 {
+ return candidates, nil
+ }
+
+ // 2. Fallback: LOGONSERVER environment variable,
+ // typically only available on Windows interactive sessions
+ log.Debug("attempting discovery via LOGONSERVER environment variable")
+ candidates = append(candidates, findLogonServer(domain, true, log)...)
+ candidates = append(candidates, findLogonServer(domain, false, log)...)
+
+ if len(candidates) == 0 {
+ log.Warnw("no LDAP servers discovered", "dns_srv_attempted", true, "logonserver_attempted", runtime.GOOS == "windows")
+ return nil, errNoLDAPServerFound
+ }
+
+ log.Infow("LDAP server auto-discovery completed", "total_candidates", len(candidates), "candidates", candidates)
+ return candidates, nil
+}
+
+func discoverDomain(configDomain string, log *logp.Logger) string {
+ if configDomain != "" {
+ return normalizeDomain(configDomain)
+ }
+ d, err := discoverDomainInPlatform()
+ if err != nil {
+ log.Warnw("failed to discover domain in platform", "error", err)
+ return ""
+ }
+ log.Infow("discovered domain in platform", "domain", d)
+ return normalizeDomain(d)
+}
+
+func normalizeDomain(domain string) string {
+ return strings.ToLower(strings.TrimSpace(domain))
+}
+
+func getDomainHostname() (string, error) {
+ h, err := os.Hostname()
+ if err != nil {
+ return "", err
+ }
+ parts := strings.Split(h, ".")
+ if len(parts) > 1 {
+ return strings.Join(parts[1:], "."), nil
+ }
+ return "", fmt.Errorf("not FQDN")
+}
+
+func lookupSRVServers(domain string, useTLS bool, log *logp.Logger) []string {
+ service := "ldap"
+ scheme := "ldap"
+ if useTLS {
+ service = "ldaps"
+ scheme = "ldaps"
+ }
+
+ log.Infow("executing DNS SRV lookup", "domain", domain, "service", service)
+ _, records, err := net.LookupSRV(service, "tcp", domain)
+ if err != nil || len(records) == 0 {
+ log.Debugw("DNS SRV lookup failed", "domain", domain, "error", err)
+ return nil
+ }
+ log.Infow("DNS SRV lookup succeeded", "domain", domain, "record_count", len(records))
+
+ // Even if the DNS server *usually* sorts them, we enforce it here
+ // to ensure we don't accidentally hit a DR site first.
+ slices.SortFunc(records, func(a, b *net.SRV) int {
+ // 1. Lower Priority is better (RFC 2782)
+ if c := cmp.Compare(a.Priority, b.Priority); c != 0 {
+ return c
+ }
+ // 2. Higher Weight is better (RFC 2782)
+ return cmp.Compare(b.Weight, a.Weight)
+ })
+
+ var addresses []string
+ for _, addr := range records {
+ target := strings.TrimSuffix(addr.Target, ".")
+ addresses = append(addresses, fmt.Sprintf("%s://%s:%d", scheme, target, addr.Port))
+ }
+ log.Infow("discovered servers via DNS SRV", "scheme", scheme, "domain", domain, "count", len(addresses), "addresses", addresses)
+ return addresses
+}
+
+// findLogonServer attempts to construct a valid FQDN from the LOGONSERVER env var.
+// It requires the previously discovered domain to ensure TLS validation works.
+func findLogonServer(domain string, useTLS bool, log *logp.Logger) []string {
+ logonServer := os.Getenv("LOGONSERVER")
+ if logonServer == "" {
+ log.Debug("LOGONSERVER environment variable not set")
+ return nil
+ }
+
+ // 1. Sanitize: Remove leading backslashes (Windows format: \\SERVERNAME)
+ serverName := strings.TrimPrefix(logonServer, `\\`)
+ if serverName == "" {
+ return nil
+ }
+
+ scheme := "ldap"
+ port := 389
+ if useTLS {
+ scheme = "ldaps"
+ port = 636
+ }
+
+ var addresses []string
+
+ // 2. Option A: The FQDN (Best for TLS)
+ // If we have a domain, and the serverName isn't already fully qualified, join them.
+ if domain != "" && !strings.Contains(serverName, ".") {
+ fqdn := fmt.Sprintf("%s.%s", serverName, domain)
+ log.Debugw("constructed FQDN from LOGONSERVER", "original", serverName, "fqdn", fqdn)
+ // Return FQDN first - this has the highest chance of passing TLS checks
+ addresses = append(addresses, fmt.Sprintf("%s://%s:%d", scheme, fqdn, port))
+ }
+
+ // 3. Option B: The NetBIOS Name (Fallback)
+ // We add this just in case the FQDN construction was wrong,
+ // though this will likely fail TLS validation unless InsecureSkipVerify is used.
+ addresses = append(addresses, fmt.Sprintf("%s://%s:%d", scheme, serverName, port))
+
+ log.Infow("discovered server via LOGONSERVER", "addresses", addresses)
+ return addresses
+}
diff --git a/libbeat/processors/translate_ldap_attribute/discovery_other.go b/libbeat/processors/translate_ldap_attribute/discovery_other.go
new file mode 100644
index 000000000000..045d29953d99
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/discovery_other.go
@@ -0,0 +1,75 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !windows && !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// discoverDomainInPlatform Chain: Resolv.conf -> Hostname -> Krb5.conf
+func discoverDomainInPlatform() (string, error) {
+ if d, err := getDomainResolv(); err == nil && d != "" {
+ return d, nil
+ }
+ if d, err := getDomainKrbConf(); err == nil && d != "" {
+ return d, nil
+ }
+ if d, err := getDomainHostname(); err == nil && d != "" {
+ return d, nil
+ }
+ return "", fmt.Errorf("domain discovery failed")
+}
+
+func getDomainResolv() (string, error) {
+ f, err := os.Open("/etc/resolv.conf")
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ s := bufio.NewScanner(f)
+ for s.Scan() {
+ fields := strings.Fields(s.Text())
+ if len(fields) > 1 && (fields[0] == "search" || fields[0] == "domain") {
+ return fields[1], nil
+ }
+ }
+ return "", nil
+}
+
+func getDomainKrbConf() (string, error) {
+ f, err := os.Open("/etc/krb5.conf")
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ s := bufio.NewScanner(f)
+ for s.Scan() {
+ line := strings.TrimSpace(s.Text())
+ if strings.HasPrefix(line, "default_realm") {
+ if _, rhs, ok := strings.Cut(line, "="); ok {
+ return strings.ToLower(strings.TrimSpace(rhs)), nil
+ }
+ }
+ }
+ return "", nil
+}
diff --git a/libbeat/processors/translate_ldap_attribute/discovery_windows.go b/libbeat/processors/translate_ldap_attribute/discovery_windows.go
new file mode 100644
index 000000000000..1a0b24978996
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/discovery_windows.go
@@ -0,0 +1,87 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build windows && !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+ "golang.org/x/sys/windows/registry"
+)
+
+// discoverDomainInPlatform Chain: Env -> API -> Reg(TCP) -> Reg(Krb) -> Hostname
+func discoverDomainInPlatform() (string, error) {
+ if d := os.Getenv("USERDNSDOMAIN"); d != "" {
+ return d, nil
+ }
+ if d, err := getDomainAPI(); err == nil && d != "" {
+ return d, nil
+ }
+ if d, err := getDomainReg(); err == nil && d != "" {
+ return d, nil
+ }
+ if d, err := getDomainKrb(); err == nil && d != "" {
+ return d, nil
+ }
+ if d, err := getDomainHostname(); err == nil && d != "" {
+ return d, nil
+ }
+ return "", fmt.Errorf("domain discovery failed")
+}
+
+func getDomainAPI() (string, error) {
+ const ComputerNameDnsDomain = 2
+ k32 := windows.NewLazySystemDLL("kernel32.dll")
+ proc := k32.NewProc("GetComputerNameExW")
+ var n uint32
+ proc.Call(uintptr(ComputerNameDnsDomain), 0, uintptr(unsafe.Pointer(&n)))
+ if n == 0 {
+ return "", fmt.Errorf("size 0")
+ }
+ b := make([]uint16, n)
+ r, _, _ := proc.Call(uintptr(ComputerNameDnsDomain), uintptr(unsafe.Pointer(&b[0])), uintptr(unsafe.Pointer(&n)))
+ if r == 0 {
+ return "", fmt.Errorf("failed")
+ }
+ return syscall.UTF16ToString(b), nil
+}
+
+func getDomainReg() (string, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`, registry.QUERY_VALUE)
+ if err != nil {
+ return "", err
+ }
+ defer k.Close()
+ val, _, err := k.GetStringValue("Domain")
+ return val, err
+}
+
+func getDomainKrb() (string, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Parameters`, registry.QUERY_VALUE)
+ if err != nil {
+ return "", err
+ }
+ defer k.Close()
+ val, _, err := k.GetStringValue("DefaultRealm")
+ return val, err
+}
diff --git a/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc b/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc
index aff1125f43a2..bc8b98a1ba47 100644
--- a/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc
+++ b/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc
@@ -1,38 +1,37 @@
-[[processor-translate-guid]]
-=== Translate GUID
+[[processor-translate-ldap-attribute]]
+=== Translate LDAP Attribute
++++
translate_ldap_attribute
++++
-The `translate_ldap_attribute` processor translates an LDAP attributes between eachother.
-It is typically used to translate AD Global Unique Identifiers (GUID)
-into their common names.
+The `translate_ldap_attribute` processor translates LDAP attributes into friendlier
+values. The typical use case is converting an Active Directory Global Unique
+Identifier (GUID) into a human-readable name (for example the object's `cn`).
Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes
refer to their GUID's rather than the object's name and these values
sometimes appear in logs.
-If the search attribute is invalid (malformed) or does not map to any object on the domain
-then this will result in the processor returning an error unless `ignore_failure`
-is set.
+If the search attribute is invalid (malformed) or does not map to any object on the
+domain the processor returns an error unless `ignore_failure` is set.
The result of this operation is an array of values, given that a single attribute
can hold multiple values.
-Note: the search attribute is expected to map to a single object. If it doesn't,
-no error will be returned, but only results of the first entry will be added
-to the event.
+The search attribute is expected to map to a single object. If multiple
+entries match, only the first entry's mapped attribute values are returned.
[source,yaml]
----
processors:
- translate_ldap_attribute:
field: winlog.event_data.ObjectGuid
- ldap_address: "ldap://"
- ldap_base_dn: "dc=example,dc=com"
ignore_missing: true
ignore_failure: true
+ # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints
+ # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted
+ # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used
----
The `translate_ldap_attribute` processor has the following configuration settings:
@@ -43,20 +42,44 @@ The `translate_ldap_attribute` processor has the following configuration setting
| Name | Required | Default | Description
| `field` | yes | | Source field containing a GUID.
| `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place.
-| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389`
-| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com`
-| `ldap_bind_user` | no | | LDAP user.
-| `ldap_bind_password` | no | | LDAP password.
+| `ldap_domain` | no | | DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname).
+| `ldap_address` | no | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds.
+| `ldap_base_dn` | no | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually.
+| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (helpful when servers expect a bind DN even for anonymous operations).
+| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service/user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms currently fall back to an unauthenticated bind immediately.
| `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by.
| `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to.
| `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds.
-| `ldap_ssl`* | no | 30 | LDAP TLS/SSL connection settings.
+| `ldap_ssl` | no | | LDAP TLS/SSL connection settings, see <>.
+| `ad_guid_translation` | no | `auto` | Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it.
| `ignore_missing` | no | false | Ignore errors when the source field is missing.
| `ignore_failure` | no | false | Ignore all errors produced by the processor.
|======
* Also see <> for a full description of the `ldap_ssl` options.
+==== Authentication flow
+
+Beats attempts LDAP authentication in the following order:
+
+. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied.
+. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System services and gMSA accounts. Other platforms do not yet implement automatic authentication.
+. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously.
+
+Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates.
+
+==== Server auto-discovery
+
+When `ldap_address` is omitted Beats resolves controllers dynamically:
+
+. *Domain discovery.* Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP registry keys, Kerberos defaults, and the host's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped.
+. *DNS SRV queries.* With a domain available Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs.
+. *Windows `LOGONSERVER` fallback.* If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid.
+
+Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed.
+
+When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly.
+
If the searches are slow or you expect a high amount of different key attributes to be found,
consider using a cache processor to speed processing:
diff --git a/libbeat/processors/translate_ldap_attribute/guid.go b/libbeat/processors/translate_ldap_attribute/guid.go
new file mode 100644
index 000000000000..aa5642971ca5
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/guid.go
@@ -0,0 +1,127 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+var (
+ // errInvalidGUIDFormat is returned when the GUID format is invalid
+ errInvalidGUIDFormat = errors.New("invalid GUID format")
+)
+
+// guidToBytes converts a GUID string in various formats to the binary format
+// expected by Microsoft Active Directory.
+//
+// IMPORTANT: This conversion is ONLY for Microsoft Active Directory's objectGUID.
+// Do NOT use for other LDAP implementations:
+// - 389 Directory Server: Uses nsUniqueId (different format)
+// - OpenLDAP and Other LDAP: Typically use RFC 4122 standard UUIDs
+//
+// Supported input formats:
+// - {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
+// - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+// - xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+//
+// The function handles the byte-order conversion required for Microsoft GUIDs:
+// The first three components (Data1, Data2, Data3) are little-endian,
+// while the remaining bytes are in network byte order.
+//
+// Example:
+//
+// Input: "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}"
+// Output: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed}
+func guidToBytes(guid string) ([]byte, error) {
+ // Remove curly braces if present
+ guid = strings.Trim(guid, "{}")
+
+ // Remove hyphens
+ guid = strings.ReplaceAll(guid, "-", "")
+
+ // Validate length
+ if len(guid) != 32 {
+ return nil, fmt.Errorf("%w: expected 32 hex characters, got %d", errInvalidGUIDFormat, len(guid))
+ }
+
+ // Decode hex string
+ bytes, err := hex.DecodeString(guid)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", errInvalidGUIDFormat, err)
+ }
+
+ // Microsoft GUID format requires byte swapping for the first three components
+ // GUID structure: {Data1-Data2-Data3-Data4[8]}
+ // Data1: 4 bytes (little-endian)
+ // Data2: 2 bytes (little-endian)
+ // Data3: 2 bytes (little-endian)
+ // Data4: 8 bytes (big-endian/network order)
+
+ // Swap Data1 (first 4 bytes)
+ bytes[0], bytes[1], bytes[2], bytes[3] = bytes[3], bytes[2], bytes[1], bytes[0]
+
+ // Swap Data2 (next 2 bytes)
+ bytes[4], bytes[5] = bytes[5], bytes[4]
+
+ // Swap Data3 (next 2 bytes)
+ bytes[6], bytes[7] = bytes[7], bytes[6]
+
+ // Data4 remains in network byte order (no swap needed)
+
+ return bytes, nil
+}
+
+// escapeBinaryForLDAP escapes binary data for use in LDAP filters.
+// Each byte is represented as \XX where XX is the hexadecimal value.
+func escapeBinaryForLDAP(data []byte) string {
+ var sb strings.Builder
+ for _, b := range data {
+ fmt.Fprintf(&sb, "\\%02x", b)
+ }
+ return sb.String()
+}
+
+// guidBytesToString converts the binary representation used by Active Directory
+// back to the canonical GUID string format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
+func guidBytesToString(data []byte) (string, error) {
+ if len(data) != 16 {
+ return "", fmt.Errorf("%w: expected 16 bytes, got %d", errInvalidGUIDFormat, len(data))
+ }
+
+ bytes := make([]byte, 16)
+ copy(bytes, data)
+
+ // Reverse the byte swaps applied in guidToBytes (operation is symmetrical).
+ bytes[0], bytes[1], bytes[2], bytes[3] = bytes[3], bytes[2], bytes[1], bytes[0]
+ bytes[4], bytes[5] = bytes[5], bytes[4]
+ bytes[6], bytes[7] = bytes[7], bytes[6]
+
+ hexStr := hex.EncodeToString(bytes)
+ return fmt.Sprintf("%s-%s-%s-%s-%s",
+ hexStr[0:8],
+ hexStr[8:12],
+ hexStr[12:16],
+ hexStr[16:20],
+ hexStr[20:32],
+ ), nil
+}
diff --git a/libbeat/processors/translate_ldap_attribute/guid_test.go b/libbeat/processors/translate_ldap_attribute/guid_test.go
new file mode 100644
index 000000000000..d2e112d6d6b7
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/guid_test.go
@@ -0,0 +1,160 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "encoding/hex"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGUIDToBytes(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []byte
+ expectError bool
+ }{
+ {
+ name: "GUID with curly braces and hyphens",
+ input: "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}",
+ // Expected byte order after Microsoft GUID conversion:
+ // Original hex: 7fb125ee-ceaf-48ff-8385-32c516ab10ed
+ // After swap: ee25b17f-afce-ff48-8385-32c516ab10ed
+ expected: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed},
+ expectError: false,
+ },
+ {
+ name: "GUID with hyphens",
+ input: "7fb125ee-ceaf-48ff-8385-32c516ab10ed",
+ expected: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed},
+ expectError: false,
+ },
+ {
+ name: "GUID without hyphens",
+ input: "7fb125eeceaf48ff838532c516ab10ed",
+ expected: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed},
+ expectError: false,
+ },
+ {
+ name: "Another valid GUID",
+ input: "{a1b2c3d4-e5f6-0718-9293-a4b5c6d7e8f9}",
+ expected: []byte{0xd4, 0xc3, 0xb2, 0xa1, 0xf6, 0xe5, 0x18, 0x07, 0x92, 0x93, 0xa4, 0xb5, 0xc6, 0xd7, 0xe8, 0xf9},
+ expectError: false,
+ },
+ {
+ name: "Empty string",
+ input: "",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "Invalid length",
+ input: "7fb125ee-ceaf-48ff-8385",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "Invalid hex characters",
+ input: "7fb125ee-ceaf-48ff-8385-32c516ab10xz",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "Too long",
+ input: "7fb125ee-ceaf-48ff-8385-32c516ab10ed-extra",
+ expected: nil,
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := guidToBytes(tt.input)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, result, "Expected: %s, Got: %s",
+ hex.EncodeToString(tt.expected),
+ hex.EncodeToString(result))
+ }
+ })
+ }
+}
+
+func TestEscapeBinaryForLDAP(t *testing.T) {
+ tests := []struct {
+ name string
+ input []byte
+ expected string
+ }{
+ {
+ name: "Simple binary data",
+ input: []byte{0x7f, 0xb1, 0x25, 0xee},
+ expected: "\\7f\\b1\\25\\ee",
+ },
+ {
+ name: "GUID binary",
+ input: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed},
+ expected: "\\ee\\25\\b1\\7f\\af\\ce\\ff\\48\\83\\85\\32\\c5\\16\\ab\\10\\ed",
+ },
+ {
+ name: "Empty byte array",
+ input: []byte{},
+ expected: "",
+ },
+ {
+ name: "Single byte",
+ input: []byte{0x00},
+ expected: "\\00",
+ },
+ {
+ name: "High value bytes",
+ input: []byte{0xff, 0xfe, 0xfd},
+ expected: "\\ff\\fe\\fd",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := escapeBinaryForLDAP(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestGUIDBytesToString(t *testing.T) {
+ original := "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}"
+ bytes, err := guidToBytes(original)
+ require.NoError(t, err)
+
+ guid, err := guidBytesToString(bytes)
+ require.NoError(t, err)
+ assert.Equal(t, strings.Trim(strings.ToLower(original), "{}"), guid)
+
+ _, err = guidBytesToString([]byte{0x00})
+ assert.Error(t, err)
+}
diff --git a/libbeat/processors/translate_ldap_attribute/ldap.go b/libbeat/processors/translate_ldap_attribute/ldap.go
index 8eb2c8814f5a..6e772aab6641 100644
--- a/libbeat/processors/translate_ldap_attribute/ldap.go
+++ b/libbeat/processors/translate_ldap_attribute/ldap.go
@@ -22,6 +22,8 @@ package translate_ldap_attribute
import (
"crypto/tls"
"fmt"
+ "net/url"
+ "strings"
"sync"
"github.com/go-ldap/ldap/v3"
@@ -50,7 +52,9 @@ type ldapConfig struct {
tlsConfig *tls.Config
}
-// newLDAPClient initializes a new ldapClient with a single connection
+// newLDAPClient initializes a new ldapClient with a single connection.
+// If baseDN is empty, it will attempt to discover it via rootDSE or domain inference.
+// It also detects whether the server is Active Directory.
func newLDAPClient(config *ldapConfig, log *logp.Logger) (*ldapClient, error) {
client := &ldapClient{ldapConfig: config, log: log}
@@ -61,10 +65,21 @@ func newLDAPClient(config *ldapConfig, log *logp.Logger) (*ldapClient, error) {
}
client.conn = conn
+ if client.baseDN == "" {
+ // Discover base DN if not provided
+ baseDN, err := client.getBaseDN()
+ if err != nil {
+ client.close()
+ return nil, fmt.Errorf("failed to discover base DN: %w", err)
+ }
+ client.baseDN = baseDN
+ }
+
return client, nil
}
-// dial establishes a new connection to the LDAP server
+// dial establishes a new connection to the LDAP server.
+// It handles the upgrade to StartTLS if the scheme is ldap:// and a TLS config is present.
func (client *ldapClient) dial() (*ldap.Conn, error) {
client.log.Debugw("ldap client connecting")
@@ -73,51 +88,165 @@ func (client *ldapClient) dial() (*ldap.Conn, error) {
if client.tlsConfig != nil {
opts = append(opts, ldap.DialWithTLSConfig(client.tlsConfig))
}
+
+ // ldap.DialURL handles parsing ldap:// vs ldaps://
conn, err := ldap.DialURL(client.address, opts...)
if err != nil {
return nil, fmt.Errorf("failed to dial LDAP server: %w", err)
}
- if client.password != "" {
- client.log.Debugw("ldap client bind")
- err = conn.Bind(client.username, client.password)
- } else {
- client.log.Debugw("ldap client unauthenticated bind")
- err = conn.UnauthenticatedBind(client.username)
+ // Explicitly handle StartTLS upgrade.
+ // DialURL connects to 389 for "ldap://" but does not upgrade automatically.
+ // We must do this before binding credentials.
+ if strings.HasPrefix(client.address, "ldap://") && client.tlsConfig != nil {
+ if err := conn.StartTLS(client.tlsConfig); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("failed to upgrade connection to StartTLS: %w", err)
+ }
+ client.log.Debug("connection upgraded to StartTLS")
}
- if err != nil {
+ switch {
+ case client.password != "":
+ client.log.Debugw("ldap client bind with provided credentials")
+ if err = conn.Bind(client.username, client.password); err == nil {
+ return conn, nil
+ } else {
+ client.log.Debugw("ldap client bind with provided credentials failed", "error", err)
+ }
+ case client.username == "" && client.password == "":
+ client.log.Debugw("trying automatic ldap client bind")
+ if err = client.bindAuto(conn); err == nil {
+ return conn, nil
+ } else {
+ client.log.Debugw("automatic ldap client bind failed", "error", err)
+ }
+ }
+
+ client.log.Debugw("trying ldap client unauthenticated bind")
+ if err = conn.UnauthenticatedBind(client.username); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to bind to LDAP server: %w", err)
}
-
return conn, nil
}
-// reconnect checks the connection's health and reconnects if necessary
-func (client *ldapClient) connection() (*ldap.Conn, error) {
+// bindAuto attempts to authenticate using the best available platform-specific method.
+// On Windows: SSPI (Current User)
+// On Linux: Kerberos Cache (Current User)
+func (client *ldapClient) bindAuto(conn *ldap.Conn) error {
+ // Parse hostname for SPN (Service Principal Name)
+ // SPN Format: ldap/server.example.com
+ parsedURL, err := url.Parse(client.address)
+ if err != nil {
+ return fmt.Errorf("failed to parse LDAP address: %w", err)
+ }
+ // Canonicalize the SPN (Active Directory expects this format)
+ spn := fmt.Sprintf("ldap/%s", strings.ToLower(parsedURL.Hostname()))
+ return client.bindPlatformSpecific(conn, spn)
+}
+
+// getBaseDN discovers base DN (if needed) and detects server type
+func (client *ldapClient) getBaseDN() (string, error) {
+ client.log.Debug("querying rootDSE for server metadata")
+
+ // Query rootDSE with relevant attributes
+ searchRequest := ldap.NewSearchRequest(
+ "", // Empty base DN = rootDSE
+ ldap.ScopeBaseObject,
+ ldap.NeverDerefAliases,
+ 0, // No size limit
+ client.searchTimeLimit,
+ false,
+ "(objectClass=*)", // Match everything
+ []string{
+ "defaultNamingContext",
+ "namingContexts",
+ },
+ nil,
+ )
+
+ var result *ldap.SearchResult
+ err := client.withLockedConnection(func(conn *ldap.Conn) error {
+ var searchErr error
+ result, searchErr = conn.Search(searchRequest)
+ return searchErr
+ })
+ if err != nil {
+ return "", fmt.Errorf("rootDSE query failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return "", fmt.Errorf("no entries returned from rootDSE")
+ }
+
+ entry := result.Entries[0]
+
+ // 1. Prefer defaultNamingContext (Active Directory standard)
+ if values := entry.GetAttributeValues("defaultNamingContext"); len(values) > 0 {
+ client.log.Infow("discovered base DN via defaultNamingContext", "base_dn", values[0])
+ return values[0], nil
+ }
+
+ // 2. Fallback to namingContexts (OpenLDAP / Standard)
+ // We must filter out system contexts like cn=config, cn=schema, etc.
+ if values := entry.GetAttributeValues("namingContexts"); len(values) > 0 {
+ for _, v := range values {
+ lowerV := strings.ToLower(v)
+ // Skip common system contexts
+ if strings.HasPrefix(lowerV, "cn=config") ||
+ strings.HasPrefix(lowerV, "cn=schema") ||
+ strings.HasPrefix(lowerV, "cn=monitor") ||
+ strings.HasPrefix(lowerV, "cn=subschema") {
+ continue
+ }
+
+ // We return the first "reasonable" context we find.
+ // Usually, this will be the main data partition (e.g., dc=example,dc=com)
+ client.log.Infow("discovered base DN via namingContexts", "base_dn", v)
+ return v, nil
+ }
+
+ // If we iterated everything and only found system contexts (unlikely for a user DB),
+ // we default to the first one but log a warning.
+ client.log.Warnw("only system contexts found in namingContexts, defaulting to first value", "base_dn", values[0])
+ return values[0], nil
+ }
+
+ return "", fmt.Errorf("base DN not found in rootDSE")
+}
+
+// withLockedConnection runs fn while holding the client mutex and ensuring the
+// underlying LDAP connection is healthy before invoking the callback.
+func (client *ldapClient) withLockedConnection(fn func(*ldap.Conn) error) error {
client.mu.Lock()
defer client.mu.Unlock()
- // Check if the connection is still alive
+ if err := client.ensureConnectedLocked(); err != nil {
+ return err
+ }
+ return fn(client.conn)
+}
+
+func (client *ldapClient) ensureConnectedLocked() error {
if client.conn == nil || client.conn.IsClosing() {
+ // Ensure previous connection is fully closed
+ if client.conn != nil {
+ client.conn.Close()
+ }
+
conn, err := client.dial()
if err != nil {
- return nil, err
+ return err
}
client.conn = conn
}
- return client.conn, nil
+ return nil
}
// findObjectBy searches for an object and returns its mapped values.
func (client *ldapClient) findObjectBy(searchBy string) ([]string, error) {
- // Ensure the connection is alive or reconnect if necessary
- conn, err := client.connection()
- if err != nil {
- return nil, fmt.Errorf("failed to reconnect: %w", err)
- }
-
+ var result *ldap.SearchResult
// Format the filter and perform the search
filter := fmt.Sprintf("(%s=%s)", client.searchAttr, searchBy)
searchRequest := ldap.NewSearchRequest(
@@ -126,8 +255,12 @@ func (client *ldapClient) findObjectBy(searchBy string) ([]string, error) {
filter, []string{client.mappedAttr}, nil,
)
- // Execute search
- result, err := conn.Search(searchRequest)
+ // Execute search while holding the connection lock to avoid concurrent usage of *ldap.Conn
+ err := client.withLockedConnection(func(conn *ldap.Conn) error {
+ var searchErr error
+ result, searchErr = conn.Search(searchRequest)
+ return searchErr
+ })
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
@@ -135,9 +268,9 @@ func (client *ldapClient) findObjectBy(searchBy string) ([]string, error) {
return nil, fmt.Errorf("no entries found for search attribute %s", searchBy)
}
- // Retrieve the CN attribute
- cn := result.Entries[0].GetAttributeValues(client.mappedAttr)
- return cn, nil
+ // Retrieve the mapped attribute values
+ values := result.Entries[0].GetAttributeValues(client.mappedAttr)
+ return values, nil
}
// close closes the LDAP connection
diff --git a/libbeat/processors/translate_ldap_attribute/ldap_other.go b/libbeat/processors/translate_ldap_attribute/ldap_other.go
new file mode 100644
index 000000000000..0d53cc7ca337
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/ldap_other.go
@@ -0,0 +1,31 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !windows && !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "errors"
+
+ "github.com/go-ldap/ldap/v3"
+)
+
+// TODO: implement automatic authentication for other platforms using kerberos
+func (*ldapClient) bindPlatformSpecific(*ldap.Conn, string) error {
+ return errors.New("unsupported platform for automatic authentication")
+}
diff --git a/libbeat/processors/translate_ldap_attribute/ldap_test.go b/libbeat/processors/translate_ldap_attribute/ldap_test.go
new file mode 100644
index 000000000000..d63367ef1377
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/ldap_test.go
@@ -0,0 +1,169 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "testing"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrepareSearchFilter(t *testing.T) {
+ validGUID := "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}"
+ guidBytes, _ := guidToBytes(validGUID)
+ expectedEscaped := escapeBinaryForLDAP(guidBytes)
+
+ tests := []struct {
+ name string
+ ldapSearchAttr string
+ adGuidTranslation string
+ input string
+ expect string
+ expectErr bool
+ }{
+ {
+ name: "Auto mode converts when attribute is objectGUID",
+ ldapSearchAttr: "objectGUID",
+ input: validGUID,
+ expect: expectedEscaped,
+ },
+ {
+ name: "Auto mode is case-insensitive",
+ ldapSearchAttr: "objectguid",
+ input: validGUID,
+ expect: expectedEscaped,
+ },
+ {
+ name: "Auto mode does not convert other attribute",
+ ldapSearchAttr: "uid",
+ input: validGUID,
+ expect: validGUID,
+ },
+ {
+ name: "Explicit true converts even if attribute different",
+ ldapSearchAttr: "uid",
+ adGuidTranslation: guidTranslationAlways,
+ input: validGUID,
+ expect: expectedEscaped,
+ },
+ {
+ name: "Explicit false never converts",
+ ldapSearchAttr: "objectGUID",
+ adGuidTranslation: guidTranslationNever,
+ input: validGUID,
+ expect: validGUID,
+ },
+ {
+ name: "Invalid GUID with conversion attempt returns error",
+ ldapSearchAttr: "objectGUID",
+ input: "invalid-guid",
+ expectErr: true,
+ },
+ {
+ name: "Escapes filter characters when not converting",
+ ldapSearchAttr: "uid",
+ input: "value*)(|(cn=*)",
+ expect: ldap.EscapeFilter("value*)(|(cn=*)"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := &processor{
+ config: config{
+ LDAPSearchAttribute: tt.ldapSearchAttr,
+ ADGUIDTranslation: tt.adGuidTranslation,
+ },
+ client: &ldapClient{},
+ }
+ out, err := p.prepareSearchFilter(tt.input)
+ if tt.expectErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.expect, out)
+ })
+ }
+}
+
+func TestMaybeConvertMappedGUID(t *testing.T) {
+ guidStr := "7fb125ee-ceaf-48ff-8385-32c516ab10ed"
+ raw, err := guidToBytes(guidStr)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ cfg config
+ values []string
+ expect []string
+ expectErr bool
+ }{
+ {
+ name: "auto converts objectGUID",
+ cfg: config{
+ LDAPMappedAttribute: "objectGUID",
+ ADGUIDTranslation: guidTranslationAuto,
+ },
+ values: []string{string(raw)},
+ expect: []string{guidStr},
+ },
+ {
+ name: "never leaves binary untouched",
+ cfg: config{
+ LDAPMappedAttribute: "objectGUID",
+ ADGUIDTranslation: guidTranslationNever,
+ },
+ values: []string{string(raw)},
+ expect: []string{string(raw)},
+ },
+ {
+ name: "non objectGUID attribute is ignored",
+ cfg: config{
+ LDAPMappedAttribute: "cn",
+ },
+ values: []string{string(raw)},
+ expect: []string{string(raw)},
+ },
+ {
+ name: "invalid length returns error",
+ cfg: config{
+ LDAPMappedAttribute: "objectGUID",
+ },
+ values: []string{"short"},
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := &processor{config: tt.cfg}
+ converted, err := p.maybeConvertMappedGUID(tt.values)
+ if tt.expectErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.expect, converted)
+ })
+ }
+}
diff --git a/libbeat/processors/translate_ldap_attribute/ldap_windows.go b/libbeat/processors/translate_ldap_attribute/ldap_windows.go
new file mode 100644
index 000000000000..8bf4937b74f6
--- /dev/null
+++ b/libbeat/processors/translate_ldap_attribute/ldap_windows.go
@@ -0,0 +1,45 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build windows && !requirefips
+
+package translate_ldap_attribute
+
+import (
+ "fmt"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/go-ldap/ldap/v3/gssapi"
+)
+
+func (client *ldapClient) bindPlatformSpecific(conn *ldap.Conn, spn string) error {
+ client.log.Info("Attempting Windows SSPI Bind")
+
+ sspiClient, err := gssapi.NewSSPIClient()
+ if err != nil {
+ return fmt.Errorf("failed to create SSPI client: %w", err)
+ }
+ defer sspiClient.DeleteSecContext()
+
+ err = conn.GSSAPIBind(sspiClient, spn, "")
+ if err != nil {
+ return fmt.Errorf("SSPI bind failed: %w", err)
+ }
+
+ client.log.Info("Windows SSPI Bind Successful")
+ return nil
+}
diff --git a/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go b/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go
index 634e0f2f4252..40b4e79cf9a0 100644
--- a/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go
+++ b/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go
@@ -22,6 +22,11 @@ package translate_ldap_attribute
import (
"errors"
"fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/go-ldap/ldap/v3"
"github.com/elastic/beats/v7/libbeat/beat"
"github.com/elastic/beats/v7/libbeat/processors"
@@ -32,7 +37,10 @@ import (
"github.com/elastic/elastic-agent-libs/transport/tlscommon"
)
-const logName = "processor.translate_ldap_attribute"
+const (
+ logName = "processor.translate_ldap_attribute"
+ clientRetryBackoff = 30 * time.Second
+)
var errInvalidType = errors.New("search attribute field value is not a string")
@@ -45,6 +53,10 @@ type processor struct {
config
client *ldapClient
log *logp.Logger
+
+ clientMu sync.Mutex
+ clientErr error
+ nextClientAttempt time.Time
}
func New(cfg *conf.C, log *logp.Logger) (beat.Processor, error) {
@@ -52,13 +64,37 @@ func New(cfg *conf.C, log *logp.Logger) (beat.Processor, error) {
if err := cfg.Unpack(&c); err != nil {
return nil, fmt.Errorf("fail to unpack the translate_ldap_attribute configuration: %w", err)
}
+ if err := c.validate(); err != nil {
+ return nil, fmt.Errorf("invalid translate_ldap_attribute configuration: %w", err)
+ }
return newFromConfig(c, log)
}
func newFromConfig(c config, logger *logp.Logger) (*processor, error) {
+ p := &processor{config: c}
+ p.log = logger.Named(logName).With(logp.Stringer("processor", p))
+ return p, nil
+}
+
+// newClient creates a new LDAP client by discovering and connecting to available servers.
+func newClient(c config, log *logp.Logger) (*ldapClient, error) {
+ // Auto-discover LDAP addresses if not provided
+ var addresses []string
+ if c.LDAPAddress != "" {
+ addresses = []string{c.LDAPAddress}
+ } else {
+ log.Info("LDAP address not configured, attempting auto-discovery")
+ discoveredAddresses, err := discoverLDAPAddress(c.LDAPDomain, log)
+ if err != nil {
+ return nil, fmt.Errorf("failed to auto-discover LDAP server: %w", err)
+ }
+ addresses = discoveredAddresses
+ log.Infow("discovered LDAP servers", "count", len(addresses), "addresses", addresses)
+ }
+
+ // Prepare base LDAP config
ldapConfig := &ldapConfig{
- address: c.LDAPAddress,
baseDN: c.LDAPBaseDN,
username: c.LDAPBindUser,
password: c.LDAPBindPassword,
@@ -67,20 +103,34 @@ func newFromConfig(c config, logger *logp.Logger) (*processor, error) {
searchTimeLimit: c.LDAPSearchTimeLimit,
}
if c.LDAPTLS != nil {
- tlsConfig, err := tlscommon.LoadTLSConfig(c.LDAPTLS, logger)
+ tlsConfig, err := tlscommon.LoadTLSConfig(c.LDAPTLS, log)
if err != nil {
return nil, fmt.Errorf("could not load provided LDAP TLS configuration: %w", err)
}
ldapConfig.tlsConfig = tlsConfig.ToConfig()
}
- p := &processor{config: c}
- p.log = logger.Named(logName).With(logp.Stringer("processor", p))
- client, err := newLDAPClient(ldapConfig, p.log)
- if err != nil {
- return nil, err
+
+ // Try each discovered address in order until one succeeds
+ var lastErr error
+ for i, address := range addresses {
+ log.Debugw("attempting to connect to LDAP server", "attempt", i+1, "total", len(addresses), "address", address)
+ ldapConfig.address = address
+
+ // newLDAPClient handles connection, base DN discovery, and AD detection
+ client, err := newLDAPClient(ldapConfig, log)
+ if err != nil {
+ log.Debugw("failed to initialize LDAP client", "address", address, "error", err)
+ lastErr = err
+ continue
+ }
+
+ // Successfully connected and initialized
+ log.Infow("successfully connected to LDAP server", "address", address, "base_dn", client.baseDN)
+ return client, nil
}
- p.client = client
- return p, nil
+
+ // All addresses failed
+ return nil, fmt.Errorf("failed to connect to any discovered LDAP server (%d addresses tried): %w", len(addresses), lastErr)
}
func (p *processor) String() string {
@@ -105,32 +155,135 @@ func (p *processor) Run(event *beat.Event) (*beat.Event, error) {
}
func (p *processor) translateLDAPAttr(event *beat.Event) error {
+ client, err := p.ensureClient()
+ if err != nil {
+ return err
+ }
+
v, err := event.GetValue(p.Field)
if err != nil {
return err
}
- guidString, ok := v.(string)
+ searchValue, ok := v.(string)
if !ok {
return errInvalidType
}
- p.log.Debugw("ldap search", "guid", guidString)
- cn, err := p.client.findObjectBy(guidString)
- p.log.Debugw("ldap result", "common_name", cn)
+ searchFilter, err := p.prepareSearchFilter(searchValue)
+ if err != nil {
+ return err
+ }
+
+ p.log.Debugw("ldap search", "search_value", searchValue, "filter_value", searchFilter)
+ values, err := client.findObjectBy(searchFilter)
+ p.log.Debugw("ldap result", "common_name", values, "error", err)
if err != nil {
return err
}
+ values, err = p.maybeConvertMappedGUID(values)
+ if err != nil {
+ return fmt.Errorf("objectGUID conversion failed: %w", err)
+ }
+
field := p.Field
if p.TargetField != "" {
field = p.TargetField
}
- _, err = event.PutValue(field, cn)
+ _, err = event.PutValue(field, values)
return err
}
+// prepareSearchFilter converts the search value to the appropriate format for LDAP queries.
+// It applies GUID binary conversion when required based on the ADGUIDTranslation configuration
+// and server type detection.
+func (p *processor) prepareSearchFilter(searchValue string) (string, error) {
+ // Determine if GUID conversion should be applied
+ var shouldConvertGUID bool
+ switch p.ADGUIDTranslation {
+ case guidTranslationAlways:
+ shouldConvertGUID = true
+ case guidTranslationNever:
+ shouldConvertGUID = false
+ default: // auto
+ shouldConvertGUID = strings.EqualFold(p.LDAPSearchAttribute, "objectGUID")
+ }
+
+ if !shouldConvertGUID {
+ return ldap.EscapeFilter(searchValue), nil
+ }
+
+ guidBytes, err := guidToBytes(searchValue)
+ if err != nil {
+ return "", fmt.Errorf("failed to convert GUID: %w", err)
+ }
+ searchFilter := escapeBinaryForLDAP(guidBytes)
+ return searchFilter, nil
+}
+
+// maybeConvertMappedGUID converts binary LDAP responses to canonical GUID strings
+// when AD GUID translation is enabled and the mapped attribute refers to objectGUID.
+func (p *processor) maybeConvertMappedGUID(values []string) ([]string, error) {
+ if !p.shouldConvertMappedGUID() || len(values) == 0 {
+ return values, nil
+ }
+
+ converted := make([]string, len(values))
+ for i, raw := range values {
+ guid, err := guidBytesToString([]byte(raw))
+ if err != nil {
+ return nil, err
+ }
+ converted[i] = guid
+ }
+ return converted, nil
+}
+
+func (p *processor) shouldConvertMappedGUID() bool {
+ if p.ADGUIDTranslation == guidTranslationNever {
+ return false
+ }
+ return strings.EqualFold(p.LDAPMappedAttribute, "objectGUID")
+}
+
func (p *processor) Close() error {
- p.client.close()
+ p.clientMu.Lock()
+ defer p.clientMu.Unlock()
+ if p.client != nil {
+ p.client.close()
+ p.client = nil
+ }
+ p.clientErr = nil
+ p.nextClientAttempt = time.Time{}
return nil
}
+
+func (p *processor) ensureClient() (*ldapClient, error) {
+ p.clientMu.Lock()
+ defer p.clientMu.Unlock()
+
+ if p.client != nil {
+ return p.client, nil
+ }
+
+ now := time.Now()
+ if !p.nextClientAttempt.IsZero() && now.Before(p.nextClientAttempt) && p.clientErr != nil {
+ return nil, fmt.Errorf("ldap client initialization paused until %s: %w", p.nextClientAttempt.Format(time.RFC3339), p.clientErr)
+ }
+
+ client, err := newClient(p.config, p.log)
+ if err != nil {
+ p.clientErr = err
+ p.nextClientAttempt = now.Add(clientRetryBackoff)
+ return nil, err
+ }
+
+ // Update config with discovered values for logging/debugging.
+ p.client = client
+ p.LDAPBaseDN = client.baseDN
+ p.LDAPAddress = client.address
+ p.clientErr = nil
+ p.nextClientAttempt = time.Time{}
+ return client, nil
+}