Skip to content

Commit b130dc8

Browse files
mprpicclaude
andauthored
Migrate LDAP integration from legacy to IPA (#973)
Migrate from ldap.corp.redhat.com (anonymous binds, deprecated) to ipa.corp.redhat.com (authenticated binds via service account). Go client: - Replace skipTLSVerify with bindDN, bindPassword, caCertPath params - connect() loads custom CA cert (appended to system pool) and performs authenticated Bind() - Default groupBaseDN derivation updated from ou=managedGroups to cn=groups (IPA pattern) - Deduplicate group memberships Manifests: - Update ldap-config.yaml (all overlays) with IPA URL and base DNs, add LDAP_BIND_DN, remove LDAP_SKIP_TLS_VERIFY - Add ldap-credentials.yaml Secret (all overlays) for LDAP_BIND_PASSWORD - Add LDAP_CA_CERT_PATH env var and ldap-ca-cert volume mount to backend deployment (optional, for TLS verification) Signed-off-by: Martin Prpič <mprpic@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 17161ac commit b130dc8

12 files changed

Lines changed: 243 additions & 47 deletions

File tree

components/backend/ldap/client.go

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package ldap
33

44
import (
55
"crypto/tls"
6+
"crypto/x509"
67
"fmt"
78
"net"
9+
"os"
810
"strings"
911
"sync"
1012
"time"
@@ -52,47 +54,123 @@ type cacheEntry struct {
5254

5355
// Client provides LDAP search functionality with in-memory caching.
5456
type Client struct {
55-
url string
56-
baseDN string
57-
groupBaseDN string
58-
skipTLSVerify bool
59-
cache sync.Map
60-
cacheTTL time.Duration
57+
url string // explicit LDAP URL (fallback if srvDomain is empty)
58+
srvDomain string // domain for DNS SRV lookup (e.g. "ipa.redhat.com")
59+
baseDN string
60+
groupBaseDN string
61+
bindDN string
62+
bindPassword string
63+
tlsConfig *tls.Config
64+
cache sync.Map
65+
cacheTTL time.Duration
6166
}
6267

6368
// NewClient creates a new LDAP client.
64-
// baseDN is the base DN for user searches (e.g. "ou=users,dc=redhat,dc=com").
69+
// url is the LDAP server URL, used as a fallback when srvDomain is empty.
70+
// srvDomain enables DNS SRV discovery (e.g. "ipa.redhat.com" queries _ldap._tcp.ipa.redhat.com).
71+
// baseDN is the base DN for user searches (e.g. "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com").
6572
// groupBaseDN is the base DN for group searches. If empty, it is derived from
66-
// baseDN by replacing the first OU with "ou=managedGroups".
67-
func NewClient(url, baseDN, groupBaseDN string, skipTLSVerify bool) *Client {
73+
// baseDN by replacing the first component with "cn=groups".
74+
// bindDN and bindPassword are used for authenticated binds (required for IPA).
75+
// caCertPath is an optional path to a PEM CA certificate file to trust.
76+
func NewClient(url, srvDomain, baseDN, groupBaseDN, bindDN, bindPassword, caCertPath string) (*Client, error) {
6877
if groupBaseDN == "" {
69-
groupBaseDN = "ou=managedGroups,dc=redhat,dc=com"
7078
if parts := strings.SplitN(baseDN, ",", 2); len(parts) == 2 {
71-
groupBaseDN = "ou=managedGroups," + parts[1]
79+
groupBaseDN = "cn=groups," + parts[1]
7280
}
7381
}
7482

75-
return &Client{
76-
url: url,
77-
baseDN: baseDN,
78-
groupBaseDN: groupBaseDN,
79-
skipTLSVerify: skipTLSVerify,
80-
cacheTTL: defaultCacheTTL,
83+
tlsConfig := &tls.Config{
84+
MinVersion: tls.VersionTLS12,
85+
}
86+
if caCertPath != "" {
87+
rootCAs, err := x509.SystemCertPool()
88+
if err != nil {
89+
rootCAs = x509.NewCertPool()
90+
}
91+
caCert, err := os.ReadFile(caCertPath)
92+
if err != nil {
93+
return nil, fmt.Errorf("ldap read CA cert %s: %w", caCertPath, err)
94+
}
95+
if !rootCAs.AppendCertsFromPEM(caCert) {
96+
return nil, fmt.Errorf("ldap CA cert %s: no valid PEM certificates found", caCertPath)
97+
}
98+
tlsConfig.RootCAs = rootCAs
8199
}
100+
101+
return &Client{
102+
url: url,
103+
srvDomain: srvDomain,
104+
baseDN: baseDN,
105+
groupBaseDN: groupBaseDN,
106+
bindDN: bindDN,
107+
bindPassword: bindPassword,
108+
tlsConfig: tlsConfig,
109+
cacheTTL: defaultCacheTTL,
110+
}, nil
82111
}
83112

84-
// connect dials the LDAP server and returns a connection.
113+
// connect dials an LDAP server (discovered via SRV or explicit URL) and performs an authenticated bind.
85114
func (c *Client) connect() (*goldap.Conn, error) {
86-
conn, err := goldap.DialURL(c.url, goldap.DialWithTLSConfig(&tls.Config{
87-
MinVersion: tls.VersionTLS12,
88-
InsecureSkipVerify: c.skipTLSVerify, //nolint:gosec // controlled by LDAP_SKIP_TLS_VERIFY env var for dev
89-
}), goldap.DialWithDialer(&net.Dialer{Timeout: defaultConnTimeout}))
115+
conn, err := c.dial()
90116
if err != nil {
91-
return nil, fmt.Errorf("ldap dial %s: %w", c.url, err)
117+
return nil, err
118+
}
119+
120+
if c.bindDN != "" {
121+
if err := conn.Bind(c.bindDN, c.bindPassword); err != nil {
122+
conn.Close()
123+
return nil, fmt.Errorf("ldap bind: %w", err)
124+
}
92125
}
126+
93127
return conn, nil
94128
}
95129

130+
// dial connects to an LDAP server. If srvDomain is configured, it discovers
131+
// servers via DNS SRV records (_ldap._tcp.<domain>) and tries each in
132+
// priority/weight order using LDAPS (port 636). Falls back to the explicit URL.
133+
func (c *Client) dial() (*goldap.Conn, error) {
134+
dialOpts := []goldap.DialOpt{
135+
goldap.DialWithTLSConfig(c.tlsConfig),
136+
goldap.DialWithDialer(&net.Dialer{Timeout: defaultConnTimeout}),
137+
}
138+
139+
if c.srvDomain == "" {
140+
conn, err := goldap.DialURL(c.url, dialOpts...)
141+
if err != nil {
142+
return nil, fmt.Errorf("ldap dial %s: %w", c.url, err)
143+
}
144+
return conn, nil
145+
}
146+
147+
_, addrs, err := net.LookupSRV("ldap", "tcp", c.srvDomain)
148+
if err != nil || len(addrs) == 0 {
149+
if c.url != "" {
150+
conn, dialErr := goldap.DialURL(c.url, dialOpts...)
151+
if dialErr != nil {
152+
return nil, fmt.Errorf("ldap SRV lookup failed (%v) and fallback dial %s failed: %w", err, c.url, dialErr)
153+
}
154+
return conn, nil
155+
}
156+
return nil, fmt.Errorf("ldap SRV lookup _ldap._tcp.%s: %w", c.srvDomain, err)
157+
}
158+
159+
// SRV records return port 389 (plain LDAP); connect on 636 for LDAPS.
160+
var lastErr error
161+
for _, addr := range addrs {
162+
host := strings.TrimSuffix(addr.Target, ".")
163+
url := fmt.Sprintf("ldaps://%s:636", host)
164+
conn, dialErr := goldap.DialURL(url, dialOpts...)
165+
if dialErr != nil {
166+
lastErr = fmt.Errorf("ldap dial %s: %w", url, dialErr)
167+
continue
168+
}
169+
return conn, nil
170+
}
171+
return nil, fmt.Errorf("ldap all SRV targets for %s failed, last error: %w", c.srvDomain, lastErr)
172+
}
173+
96174
// cacheGet returns a cached value if it exists and hasn't expired.
97175
func (c *Client) cacheGet(key string) (any, bool) {
98176
val, ok := c.cache.Load(key)
@@ -133,9 +211,13 @@ func entryToUser(entry *goldap.Entry) LDAPUser {
133211
break
134212
}
135213
}
214+
seen := make(map[string]struct{})
136215
for _, dn := range entry.GetAttributeValues("memberOf") {
137216
if cn := extractCNFromDN(dn); cn != "" {
138-
user.Groups = append(user.Groups, cn)
217+
if _, dup := seen[cn]; !dup {
218+
seen[cn] = struct{}{}
219+
user.Groups = append(user.Groups, cn)
220+
}
139221
}
140222
}
141223
return user
@@ -188,7 +270,7 @@ func (c *Client) SearchUsers(query string) ([]LDAPUser, error) {
188270
}
189271

190272
// SearchGroups searches for groups matching the query string.
191-
// Searches the cn attribute with a prefix match in ou=managedGroups.
273+
// Searches the cn attribute with a prefix match in the groups base DN.
192274
func (c *Client) SearchGroups(query string) ([]LDAPGroup, error) {
193275
query = sanitizeQuery(query)
194276
if len(query) < MinQueryLength {

components/backend/ldap/client_test.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -268,32 +268,44 @@ func TestCacheMiss(t *testing.T) {
268268
}
269269

270270
func TestNewClient(t *testing.T) {
271-
client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false)
271+
client, err := NewClient("ldaps://ldap.example.com", "", "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com", "", "uid=svc,cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com", "pass", "")
272+
if err != nil {
273+
t.Fatalf("unexpected error: %v", err)
274+
}
272275

273276
if client.url != "ldaps://ldap.example.com" {
274277
t.Errorf("expected url 'ldaps://ldap.example.com', got %q", client.url)
275278
}
276-
if client.baseDN != "ou=users,dc=redhat,dc=com" {
277-
t.Errorf("expected baseDN 'ou=users,dc=redhat,dc=com', got %q", client.baseDN)
279+
if client.baseDN != "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com" {
280+
t.Errorf("expected baseDN 'cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com', got %q", client.baseDN)
281+
}
282+
if client.groupBaseDN != "cn=groups,cn=accounts,dc=ipa,dc=redhat,dc=com" {
283+
t.Errorf("expected groupBaseDN 'cn=groups,cn=accounts,dc=ipa,dc=redhat,dc=com', got %q", client.groupBaseDN)
278284
}
279-
if client.groupBaseDN != "ou=managedGroups,dc=redhat,dc=com" {
280-
t.Errorf("expected groupBaseDN 'ou=managedGroups,dc=redhat,dc=com', got %q", client.groupBaseDN)
285+
if client.bindDN != "uid=svc,cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com" {
286+
t.Errorf("expected bindDN 'uid=svc,cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com', got %q", client.bindDN)
281287
}
282288
if client.cacheTTL != defaultCacheTTL {
283289
t.Errorf("expected cacheTTL %v, got %v", defaultCacheTTL, client.cacheTTL)
284290
}
285291
}
286292

287293
func TestNewClientExplicitGroupBaseDN(t *testing.T) {
288-
client := NewClient("ldaps://ldap.example.com", "ou=users,dc=example,dc=com", "ou=groups,dc=example,dc=com", false)
294+
client, err := NewClient("ldaps://ldap.example.com", "", "cn=users,cn=accounts,dc=example,dc=com", "cn=groups,cn=accounts,dc=example,dc=com", "", "", "")
295+
if err != nil {
296+
t.Fatalf("unexpected error: %v", err)
297+
}
289298

290-
if client.groupBaseDN != "ou=groups,dc=example,dc=com" {
291-
t.Errorf("expected explicit groupBaseDN 'ou=groups,dc=example,dc=com', got %q", client.groupBaseDN)
299+
if client.groupBaseDN != "cn=groups,cn=accounts,dc=example,dc=com" {
300+
t.Errorf("expected explicit groupBaseDN 'cn=groups,cn=accounts,dc=example,dc=com', got %q", client.groupBaseDN)
292301
}
293302
}
294303

295304
func TestSearchUsersShortQuery(t *testing.T) {
296-
client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false)
305+
client, err := NewClient("ldaps://ldap.example.com", "", "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com", "", "", "", "")
306+
if err != nil {
307+
t.Fatalf("unexpected error: %v", err)
308+
}
297309

298310
// Query too short should return nil without connecting
299311
users, err := client.SearchUsers("m")
@@ -306,7 +318,10 @@ func TestSearchUsersShortQuery(t *testing.T) {
306318
}
307319

308320
func TestSearchGroupsShortQuery(t *testing.T) {
309-
client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false)
321+
client, err := NewClient("ldaps://ldap.example.com", "", "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com", "", "", "", "")
322+
if err != nil {
323+
t.Fatalf("unexpected error: %v", err)
324+
}
310325

311326
groups, err := client.SearchGroups("a")
312327
if err != nil {
@@ -318,7 +333,10 @@ func TestSearchGroupsShortQuery(t *testing.T) {
318333
}
319334

320335
func TestGetUserEmptyUID(t *testing.T) {
321-
client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false)
336+
client, err := NewClient("ldaps://ldap.example.com", "", "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com", "", "", "", "")
337+
if err != nil {
338+
t.Fatalf("unexpected error: %v", err)
339+
}
322340

323341
user, err := client.GetUser("")
324342
if err != nil {

components/backend/main.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,24 @@ func main() {
138138
return server.Namespace
139139
}
140140

141-
// Initialize LDAP client (optional - requires LDAP_URL to be set)
141+
// Initialize LDAP client (optional - requires LDAP_URL or LDAP_SRV_DOMAIN)
142142
// Access is gated by the "ldap.autocomplete.enabled" Unleash feature flag.
143-
if ldapURL := os.Getenv("LDAP_URL"); ldapURL != "" {
144-
ldapBaseDN := getEnvOrDefault("LDAP_BASE_DN", "ou=users,dc=redhat,dc=com")
143+
ldapURL := os.Getenv("LDAP_URL")
144+
ldapSRVDomain := os.Getenv("LDAP_SRV_DOMAIN")
145+
if ldapURL != "" || ldapSRVDomain != "" {
146+
ldapBaseDN := getEnvOrDefault("LDAP_BASE_DN", "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com")
145147
ldapGroupBaseDN := os.Getenv("LDAP_GROUP_BASE_DN") // optional, derived from LDAP_BASE_DN if empty
146-
skipTLS := os.Getenv("LDAP_SKIP_TLS_VERIFY") == "true"
147-
handlers.LDAPClient = ldap.NewClient(ldapURL, ldapBaseDN, ldapGroupBaseDN, skipTLS)
148-
log.Printf("LDAP client initialized: %s (base DN: %s, group base DN: %s, skipTLSVerify: %v)", ldapURL, ldapBaseDN, ldapGroupBaseDN, skipTLS)
148+
ldapBindDN := os.Getenv("LDAP_BIND_DN")
149+
ldapBindPassword := os.Getenv("LDAP_BIND_PASSWORD")
150+
ldapCACertPath := os.Getenv("LDAP_CA_CERT_PATH")
151+
if ldapBindDN == "" || ldapBindPassword == "" {
152+
log.Printf("LDAP disabled: missing bind credentials")
153+
} else if ldapClient, err := ldap.NewClient(ldapURL, ldapSRVDomain, ldapBaseDN, ldapGroupBaseDN, ldapBindDN, ldapBindPassword, ldapCACertPath); err != nil {
154+
log.Printf("LDAP disabled: %v", err)
155+
} else {
156+
handlers.LDAPClient = ldapClient
157+
log.Printf("LDAP client configured (URL: %s, SRV domain: %s, base DN: %s)", ldapURL, ldapSRVDomain, ldapBaseDN)
158+
}
149159
}
150160

151161
// Initialize GitHub auth handlers

components/manifests/base/core/backend-deployment.yaml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ spec:
147147
key: GOOGLE_APPLICATION_CREDENTIALS
148148
optional: true
149149
# LDAP configuration (optional - autocomplete gated by ldap.autocomplete.enabled feature flag)
150+
- name: LDAP_SRV_DOMAIN
151+
valueFrom:
152+
configMapKeyRef:
153+
name: ldap-config
154+
key: LDAP_SRV_DOMAIN
155+
optional: true
150156
- name: LDAP_URL
151157
valueFrom:
152158
configMapKeyRef:
@@ -165,11 +171,23 @@ spec:
165171
name: ldap-config
166172
key: LDAP_GROUP_BASE_DN
167173
optional: true
168-
- name: LDAP_SKIP_TLS_VERIFY
174+
- name: LDAP_BIND_DN
169175
valueFrom:
170176
configMapKeyRef:
171177
name: ldap-config
172-
key: LDAP_SKIP_TLS_VERIFY
178+
key: LDAP_BIND_DN
179+
optional: true
180+
- name: LDAP_BIND_PASSWORD
181+
valueFrom:
182+
secretKeyRef:
183+
name: ldap-credentials
184+
key: LDAP_BIND_PASSWORD
185+
optional: true
186+
- name: LDAP_CA_CERT_PATH
187+
valueFrom:
188+
configMapKeyRef:
189+
name: ldap-config
190+
key: LDAP_CA_CERT_PATH
173191
optional: true
174192
# Unleash feature flags (optional - all flags disabled when not set)
175193
- name: UNLEASH_URL
@@ -246,6 +264,10 @@ spec:
246264
- name: agent-registry
247265
mountPath: /config/registry
248266
readOnly: true
267+
# Red Hat Root CA cert
268+
- name: ldap-ca-cert
269+
mountPath: /etc/pki/custom-ca
270+
readOnly: true
249271
volumes:
250272
- name: backend-state
251273
persistentVolumeClaim:
@@ -272,6 +294,11 @@ spec:
272294
configMap:
273295
name: ambient-agent-registry
274296
optional: true # Don't fail if ConfigMap not yet created
297+
# Red Hat Root CA cert
298+
- name: ldap-ca-cert
299+
configMap:
300+
name: ldap-ca-cert
301+
optional: true
275302

276303
---
277304
apiVersion: v1

components/manifests/overlays/kind/kustomization.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ resources:
1616
- mlflow-db-credentials.yaml
1717
# PostgreSQL init scripts for database creation (kind only)
1818
- postgresql-init-scripts.yaml
19+
- ldap-config.yaml
20+
- ldap-credentials.yaml
1921

2022
# Patches for e2e environment
2123
patches:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: ldap-config
5+
labels:
6+
app: backend-api
7+
data:
8+
LDAP_URL: "ldaps://ipa.corp.redhat.com"
9+
LDAP_BASE_DN: "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com"
10+
LDAP_GROUP_BASE_DN: "cn=groups,cn=accounts,dc=ipa,dc=redhat,dc=com"
11+
LDAP_BIND_DN: "uid=ambient-code-platform,cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com"
12+
LDAP_CA_CERT_PATH: "/etc/pki/custom-ca/rh-it-root-ca.pem"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: v1
2+
kind: Secret
3+
metadata:
4+
name: ldap-credentials
5+
labels:
6+
app: backend-api
7+
type: Opaque
8+
stringData:
9+
LDAP_BIND_PASSWORD: "REPLACE_WITH_ACTUAL_PASSWORD"

components/manifests/overlays/local-dev/kustomization.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ resources:
1313
- backend-route.yaml
1414
- frontend-route.yaml
1515
- operator-config-crc.yaml
16+
- ldap-config.yaml
17+
- ldap-credentials.yaml
1618
- unleash-credentials.yaml
1719
# mlflow-db-credentials is applied separately (lives in redhat-ods-applications,
1820
# incompatible with namePrefix and namespace directives in this overlay)

0 commit comments

Comments
 (0)