desktop discovery: support multi-valued LDAP attributes

LDAP attributes aren't always a single-key to single-value mapping.
Prior to this change, Teleport would take the first value for attributes
that specify multiple values.

This change introduces a new opt-in discovery config setting that allows
users to configure Teleport to join multiple values into a single value.
This commit is contained in:
Zac Bergquist
2026-06-12 15:25:44 -06:00
parent 9e1bb7ff36
commit aa348ddee3
8 changed files with 170 additions and 45 deletions
@@ -160,7 +160,7 @@ To create the service account:
```code
$ Get-ADUser -Identity svc-teleport
```
- If the `dsacls` commands fail, ensure you're running PowerShell as a Domain Administrator.
- Verify the LDAP containers were created by checking `CN=Teleport,CN=CDP,CN=Public Key Services,CN=Services,CN=Configuration` in AD.
@@ -475,14 +475,14 @@ the performance of remote desktop connections.
$ gpresult /r
```
- Verify the Teleport CA is in the NTAuth store:
- Verify the Teleport CA is in the NTAuth store:
```code
$ certutil -viewstore -enterprise NTAuth
```
- Verify the Teleport CA is in the Root store:
- Verify the Teleport CA is in the Root store:
```code
$ certutil -viewstore -enterprise Root
```
@@ -709,14 +709,14 @@ To configure Teleport to protect access to Windows desktops:
description="Confirm that the Teleport Windows Desktop Service has started and connected to your cluster."
>
Here are some troubleshooting tips:
- Check the service status on the Linux server:
- Check the service status on the Linux server:
```code
$ systemctl status teleport
```
- Review logs for connection errors:
- Review logs for connection errors:
```code
$ sudo journalctl -u teleport
```
@@ -725,8 +725,8 @@ To configure Teleport to protect access to Windows desktops:
- Ensure the Linux server can reach the Teleport Proxy and the LDAP server on port 636.
- Test LDAP connectivity:
- Test LDAP connectivity:
```code
$ nc -vz $LDAP_SERVER 636
```
@@ -802,8 +802,8 @@ To connect to a Windows desktop:
- Ensure your Teleport user is assigned the `windows-desktop-admins` role or a role with the same permissions.
- Review the Windows Desktop Service logs:
- Review the Windows Desktop Service logs:
```code
$ sudo journalctl -u teleport
```
@@ -827,7 +827,7 @@ appropriate user rights, and they must be added to the 'Remote Desktop Users' AD
### Add users/groups to the 'Remote Desktop Users' AD group
1. Open **Active Directory Users and Computers**
1. Open **Active Directory Users and Computers**
1. Navigate to **`$YOUR_DOMAIN -> Builtin`** and double-click **Remote Desktop Users**
1. Select the **Members** tab and click **Add**
1. (Optional) Click **Locations** and select the domain for which you wish to grant logon access to users/groups.
@@ -894,7 +894,26 @@ For example, if an AD computer object had a location attribute with a value of O
and a department attribute with a value of Engineering, the Teleport resource for this
host would have both `ldap/location=Oakland` and `ldap/department=Engineering` labels.
In addition, you can also specify a set of static labels that apply to all hosts discovered
Teleport's default behavior for multi-valued attributes is to pick the first one.
For example, a host that has an LDAP attribute `usage` with values `db` and
`ca` would result in a Teleport label of `ldap/usage=db`. You can override this
behavior by setting `label_attribute_mode: join` in your discovery config:
```yaml
windows_desktop_service:
enabled: true
discovery_configs:
- base_dn: '*'
label_attributes:
- 'usage'
label_attribute_mode: 'join'
label_attribute_join_separator: '_'
```
With this config, the Teleport label for this host would be
`ldap/usage=db_ca`. The join separator defaults to `|` if unspecified.
Lastly, you can also specify a set of static labels that apply to all hosts discovered
via this policy:
```yaml
@@ -1008,7 +1027,7 @@ the external forest. Multiple CA certificates can be written to the `ldap_ca_cer
```yaml
windows_desktop_service:
enabled: true
ldap:
addr: example.com:636
username: 'DEV\svc-teleport'
@@ -109,10 +109,16 @@ windows_desktop_service:
filters:
- "(location=Oakland)"
- "(!(primaryGroupID=516))" # exclude domain controllers
# (optional) LDAP attributes to convert into Teleport labels.
# The key of the label will be "ldap/" + the value of the attribute.
label_attributes:
- location
# For multi-valued attributes Teleport takes the first value
# by default. This can be overridden with the "join" mode.
label_attribute_mode: join # (optional) defaults to "first"
label_attribute_join_separator: "_" # (optional) defaults to '|'
# (optional) static labels to apply to all hosts discovered via this policy
labels:
env: prod
+19 -22
View File
@@ -2376,22 +2376,15 @@ func applyWindowsDesktopConfig(fc *FileConfig, cfg *servicecfg.Config) error {
cfg.WindowsDesktop.ListenAddr = *listenAddr
}
for _, attributeName := range fc.WindowsDesktop.Discovery.LabelAttributes {
if !types.IsValidLabelKey(attributeName) {
return trace.BadParameter("WindowsDesktopService specifies label_attribute %q which is not a valid label key", attributeName)
}
}
for _, filter := range fc.WindowsDesktop.Discovery.Filters {
if _, err := ldap.CompileFilter(filter); err != nil {
return trace.BadParameter("WindowsDesktopService specifies invalid LDAP filter %q", filter)
}
}
if fc.WindowsDesktop.Discovery.BaseDN != "" && len(fc.WindowsDesktop.DiscoveryConfigs) > 0 {
return trace.BadParameter("WindowsDesktopService specifies both discovery and discovery_configs: move the discovery section to discovery_configs to continue")
}
// append the old (singular) discovery config to the new format that supports multiple configs
if fc.WindowsDesktop.Discovery.BaseDN != "" {
fc.WindowsDesktop.DiscoveryConfigs = append(fc.WindowsDesktop.DiscoveryConfigs, fc.WindowsDesktop.Discovery)
}
for _, discoveryConfig := range fc.WindowsDesktop.DiscoveryConfigs {
for _, filter := range discoveryConfig.Filters {
if _, err := ldap.CompileFilter(filter); err != nil {
@@ -2408,16 +2401,18 @@ func applyWindowsDesktopConfig(fc *FileConfig, cfg *servicecfg.Config) error {
return trace.BadParameter("WindowsDesktopService specifies label_attribute %q which is not a valid label key", attributeName)
}
}
switch mode := discoveryConfig.LabelAttributeMode; mode {
case servicecfg.LabelAttributeModeJoin:
case servicecfg.LabelAttributeModeFirst, "":
default:
return trace.BadParameter("WindowsDesktopService specifies invalid label_attribute_mode %q", mode)
}
if p := discoveryConfig.RDPPort; p < 0 || p > 65535 {
return trace.BadParameter("WindowsDesktopService specifies invalid RDP port %d", p)
}
}
// append the old (singular) discovery config to the new format that supports multiple configs
if fc.WindowsDesktop.Discovery.BaseDN != "" {
fc.WindowsDesktop.DiscoveryConfigs = append(fc.WindowsDesktop.DiscoveryConfigs, fc.WindowsDesktop.Discovery)
}
cfg.WindowsDesktop.Discovery = make([]servicecfg.LDAPDiscoveryConfig, 0, len(fc.WindowsDesktop.DiscoveryConfigs))
for _, dc := range fc.WindowsDesktop.DiscoveryConfigs {
if dc.BaseDN == "" {
@@ -2425,11 +2420,13 @@ func applyWindowsDesktopConfig(fc *FileConfig, cfg *servicecfg.Config) error {
}
cfg.WindowsDesktop.Discovery = append(cfg.WindowsDesktop.Discovery,
servicecfg.LDAPDiscoveryConfig{
BaseDN: dc.BaseDN,
Filters: dc.Filters,
Labels: dc.Labels,
LabelAttributes: dc.LabelAttributes,
RDPPort: cmp.Or(dc.RDPPort, int(defaults.RDPListenPort)),
BaseDN: dc.BaseDN,
Filters: dc.Filters,
Labels: dc.Labels,
LabelAttributes: dc.LabelAttributes,
LabelAttributeMode: cmp.Or(dc.LabelAttributeMode, string(servicecfg.LabelAttributeModeFirst)),
LabelAttributeJoinSeparator: dc.LabelAttributeJoinSeparator,
RDPPort: cmp.Or(dc.RDPPort, int(defaults.RDPListenPort)),
},
)
}
+38
View File
@@ -2382,6 +2382,7 @@ uQM=
desc: "NOK - invalid label key for LDAP attribute",
expectError: require.Error,
mutate: func(fc *FileConfig) {
fc.WindowsDesktop.Discovery.BaseDN = "*"
fc.WindowsDesktop.Discovery.LabelAttributes = []string{"this?is not* a valid key 🚨"}
},
},
@@ -2451,6 +2452,19 @@ uQM=
}
},
},
{
desc: "OK - new discovery specified and ldap specified",
expectError: require.NoError,
mutate: func(fc *FileConfig) {
fc.WindowsDesktop.DiscoveryConfigs = []LDAPDiscoveryConfig{
{BaseDN: "something"},
}
fc.WindowsDesktop.LDAP = LDAPConfig{
Addr: "something",
Domain: "example.com",
}
},
},
{
desc: "OK - discovery not specified and ldap not specified",
expectError: require.NoError,
@@ -2491,6 +2505,30 @@ uQM=
}
},
},
{
desc: "NOK - invalid label attribute mode",
expectError: require.Error,
mutate: func(fc *FileConfig) {
fc.WindowsDesktop.DiscoveryConfigs = []LDAPDiscoveryConfig{
{
BaseDN: "*",
LabelAttributes: []string{"foo"},
LabelAttributeMode: "invalid",
},
}
},
},
{
desc: "NOK - invalid label attribute (legacy)",
expectError: require.Error,
mutate: func(fc *FileConfig) {
fc.WindowsDesktop.Discovery = LDAPDiscoveryConfig{
BaseDN: "*",
LabelAttributes: []string{"foo"},
LabelAttributeMode: "invalid",
}
},
},
{
desc: "NOK - invalid RDP port",
expectError: require.Error,
+10 -3
View File
@@ -3015,6 +3015,9 @@ type LDAPDiscoveryConfig struct {
// Filters are additional LDAP filters to apply to the search.
// See: https://ldap.com/ldap-filters/
Filters []string `yaml:"filters"`
// RDPPort is the port to use for RDP for hosts discovered with this configuration.
// Optional, defaults to 3389 if unspecified.
RDPPort int `yaml:"rdp_port"`
// Labels are static labels applied to all hosts discovered via
// this policy.
Labels map[string]string `yaml:"labels,omitempty"`
@@ -3024,9 +3027,13 @@ type LDAPDiscoveryConfig struct {
// discovered desktops having a label with key "ldap/location" and
// the value being the value of the "location" attribute.
LabelAttributes []string `yaml:"label_attributes"`
// RDPPort is the port to use for RDP for hosts discovered with this configuration.
// Optional, defaults to 3389 if unspecified.
RDPPort int `yaml:"rdp_port"`
// LabelAttributeMode determines how multi-valued LDAP attributes are
// treated. Valid values are:
// - "first" (the default if unspecified): use the first attribute value
// - "join": multi-valued attributes are joined with the specified separator
LabelAttributeMode string `yaml:"label_attribute_mode"`
// LabelAttributeJoinSeparator is used when LabelAttributeMode is "join".
LabelAttributeJoinSeparator string `yaml:"label_attribute_join_separator"`
}
// TracingService contains configuration for the tracing_service.
+19 -2
View File
@@ -100,16 +100,33 @@ type LDAPDiscoveryConfig struct {
Filters []string
// Labels are static labels to apply to hosts discovered via LDAP.
Labels map[string]string
// RDPPort is the RDP port to register for each host discovered with this configuration.
RDPPort int
// LabelAttributes are LDAP attributes to apply to hosts discovered
// via LDAP. Teleport labels hosts by prefixing the attribute with
// "ldap/" - for example, a value of "location" here would result in
// discovered desktops having a label with key "ldap/location" and
// the value being the value of the "location" attribute.
LabelAttributes []string
// RDPPort is the RDP port to register for each host discovered with this configuration.
RDPPort int
// LabelAttributeMode determines how multi-valued LDAP attributes are
// treated. Valid values are:
// - "first" (the default if unspecified): use the first attribute value
// - "join": multi-valued attributes are joined with the specified separator
LabelAttributeMode string
// LabelAttributeJoinSeparator is used when LabelAttributeMode is "join".
LabelAttributeJoinSeparator string
}
const (
// LabelAttributeModeFirst configures Teleport to select the first attribute
// value for multi-value attributes.
LabelAttributeModeFirst = "first"
// LabelAttributeModeJoin configures Teleport to join all attribute values
// into a single string.
LabelAttributeModeJoin = "join"
)
// HostLabelRules is a collection of rules describing how to apply labels to hosts.
type HostLabelRules struct {
rules []HostLabelRule
+21 -2
View File
@@ -19,6 +19,7 @@
package desktop
import (
"cmp"
"context"
"encoding/hex"
"errors"
@@ -295,9 +296,27 @@ func (s *WindowsService) applyLabelsFromLDAP(entry *ldap.Entry, labels map[strin
}
// apply any custom labels per the discovery configuration
const maxAttributeLabelLen = 512
for _, attr := range cfg.LabelAttributes {
if v := entry.GetAttributeValue(attr); v != "" {
labels[types.DiscoveryLabelLDAPPrefix+attr] = v
values := entry.GetAttributeValues(attr)
values = slices.DeleteFunc(values, func(v string) bool { return strings.TrimSpace(v) == "" })
if len(values) == 0 {
continue
}
// Take only the first value when not in join mode.
if cfg.LabelAttributeMode != servicecfg.LabelAttributeModeJoin && len(values) > 1 {
values = values[:1]
}
// Sort the attributes so the Teleport label is consistent even if AD
// returns them in a different order.
slices.Sort(values)
// At this point we can do an unconditional join, because
// strings.Join is a no-op on a single-element slice.
if value := strings.Join(values, cmp.Or(cfg.LabelAttributeJoinSeparator, "|")); len(value) > 0 && len(value) <= maxAttributeLabelLen {
labels[types.DiscoveryLabelLDAPPrefix+attr] = value
}
}
}
+23 -1
View File
@@ -88,12 +88,14 @@ func TestAppliesLDAPLabels(t *testing.T) {
attrCommonName: {"foo"},
"bar": {"baz"},
"quux": {""},
"multi": {"value1", "value2"},
"empty": {"", ""},
})
s := new(WindowsService)
s.applyLabelsFromLDAP(entry, l, &servicecfg.LDAPDiscoveryConfig{
BaseDN: "*",
LabelAttributes: []string{"bar"},
LabelAttributes: []string{"bar", "multi"},
})
// check default labels
@@ -109,6 +111,26 @@ func TestAppliesLDAPLabels(t *testing.T) {
// check custom labels
require.Equal(t, "baz", l["ldap/bar"])
require.Empty(t, l["ldap/quux"])
require.Equal(t, "value1", l["ldap/multi"]) // take first value by default
// Verify multi-valued attributes
clear(l)
s.applyLabelsFromLDAP(entry, l, &servicecfg.LDAPDiscoveryConfig{
BaseDN: "*",
LabelAttributes: []string{"multi", "empty"},
LabelAttributeMode: "join",
LabelAttributeJoinSeparator: "_",
})
require.Equal(t, "value1_value2", l["ldap/multi"])
require.NotContains(t, l, "ldap/empty")
clear(l)
s.applyLabelsFromLDAP(entry, l, &servicecfg.LDAPDiscoveryConfig{
BaseDN: "*",
LabelAttributes: []string{"multi"},
LabelAttributeMode: "join",
})
require.Equal(t, "value1|value2", l["ldap/multi"]) // default separator
}
func TestDNToDomain(t *testing.T) {