diff --git a/components/backend/go.mod b/components/backend/go.mod index 4738f7310..941705577 100644 --- a/components/backend/go.mod +++ b/components/backend/go.mod @@ -24,6 +24,7 @@ require ( cloud.google.com/go/auth v0.7.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect @@ -34,6 +35,8 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-ldap/ldap/v3 v3.4.12 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect diff --git a/components/backend/go.sum b/components/backend/go.sum index e1d26985d..809d4684b 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -5,6 +5,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAg cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= @@ -51,6 +53,10 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/components/backend/handlers/ldap.go b/components/backend/handlers/ldap.go new file mode 100644 index 000000000..4241261e3 --- /dev/null +++ b/components/backend/handlers/ldap.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + + "ambient-code-backend/ldap" + + "github.com/gin-gonic/gin" +) + +// LDAPClient is the shared LDAP client instance, initialized in main.go when LDAP is configured. +var LDAPClient *ldap.Client + +// SearchLDAPUsers handles GET /api/ldap/users?q={query} +func SearchLDAPUsers(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + query := c.Query("q") + if len(query) < ldap.MinQueryLength { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("query must be at least %d characters", ldap.MinQueryLength)}) + return + } + + if LDAPClient == nil { + c.JSON(http.StatusOK, gin.H{"users": []ldap.LDAPUser{}}) + return + } + + users, err := LDAPClient.SearchUsers(query) + if err != nil { + log.Printf("LDAP user search error for query %q: %v", query, err) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LDAP search unavailable"}) + return + } + + c.JSON(http.StatusOK, gin.H{"users": users}) +} + +// SearchLDAPGroups handles GET /api/ldap/groups?q={query} +func SearchLDAPGroups(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + query := c.Query("q") + if len(query) < ldap.MinQueryLength { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("query must be at least %d characters", ldap.MinQueryLength)}) + return + } + + if LDAPClient == nil { + c.JSON(http.StatusOK, gin.H{"groups": []ldap.LDAPGroup{}}) + return + } + + groups, err := LDAPClient.SearchGroups(query) + if err != nil { + log.Printf("LDAP group search error for query %q: %v", query, err) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LDAP search unavailable"}) + return + } + + c.JSON(http.StatusOK, gin.H{"groups": groups}) +} + +// GetLDAPUser handles GET /api/ldap/users/:uid +func GetLDAPUser(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + uid := c.Param("uid") + if uid == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"}) + return + } + + if LDAPClient == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "LDAP not configured"}) + return + } + + user, err := LDAPClient.GetUser(uid) + if err != nil { + log.Printf("LDAP user get error for uid %q: %v", uid, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to look up user"}) + return + } + + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, user) +} diff --git a/components/backend/ldap/client.go b/components/backend/ldap/client.go new file mode 100644 index 000000000..e7a773f16 --- /dev/null +++ b/components/backend/ldap/client.go @@ -0,0 +1,355 @@ +// Package ldap provides LDAP search and caching for user and group lookups. +package ldap + +import ( + "crypto/tls" + "fmt" + "net" + "strings" + "sync" + "time" + + goldap "github.com/go-ldap/ldap/v3" +) + +const ( + defaultConnTimeout = 5 * time.Second + defaultQueryTimeoutSec = 3 // LDAP search time limit in seconds + defaultMaxResults = 10 + defaultCacheTTL = 5 * time.Minute + MinQueryLength = 2 + maxQueryLength = 50 + + cacheKeyUserSearch = "users:" + cacheKeyGroupSearch = "groups:" + cacheKeyUser = "user:" +) + +// userAttributes is the list of LDAP attributes to fetch for user entries. +var userAttributes = []string{"uid", "cn", "mail", "title", "rhatSocialURL", "memberOf"} + +// LDAPUser represents a user entry from LDAP. +type LDAPUser struct { + UID string `json:"uid"` + FullName string `json:"fullName"` + Email string `json:"email"` + Title string `json:"title"` + GitHubUsername string `json:"githubUsername"` + Groups []string `json:"groups"` +} + +// LDAPGroup represents a group entry from LDAP. +type LDAPGroup struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// cacheEntry holds a cached result with expiry. +type cacheEntry struct { + value any + expiresAt time.Time +} + +// Client provides LDAP search functionality with in-memory caching. +type Client struct { + url string + baseDN string + groupBaseDN string + skipTLSVerify bool + cache sync.Map + cacheTTL time.Duration +} + +// NewClient creates a new LDAP client. +// baseDN is the base DN for user searches (e.g. "ou=users,dc=redhat,dc=com"). +// groupBaseDN is the base DN for group searches. If empty, it is derived from +// baseDN by replacing the first OU with "ou=managedGroups". +func NewClient(url, baseDN, groupBaseDN string, skipTLSVerify bool) *Client { + if groupBaseDN == "" { + groupBaseDN = "ou=managedGroups,dc=redhat,dc=com" + if parts := strings.SplitN(baseDN, ",", 2); len(parts) == 2 { + groupBaseDN = "ou=managedGroups," + parts[1] + } + } + + return &Client{ + url: url, + baseDN: baseDN, + groupBaseDN: groupBaseDN, + skipTLSVerify: skipTLSVerify, + cacheTTL: defaultCacheTTL, + } +} + +// connect dials the LDAP server and returns a connection. +func (c *Client) connect() (*goldap.Conn, error) { + conn, err := goldap.DialURL(c.url, goldap.DialWithTLSConfig(&tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: c.skipTLSVerify, //nolint:gosec // controlled by LDAP_SKIP_TLS_VERIFY env var for dev + }), goldap.DialWithDialer(&net.Dialer{Timeout: defaultConnTimeout})) + if err != nil { + return nil, fmt.Errorf("ldap dial %s: %w", c.url, err) + } + return conn, nil +} + +// cacheGet returns a cached value if it exists and hasn't expired. +func (c *Client) cacheGet(key string) (any, bool) { + val, ok := c.cache.Load(key) + if !ok { + return nil, false + } + entry, ok := val.(cacheEntry) + if !ok { + c.cache.Delete(key) + return nil, false + } + if time.Now().After(entry.expiresAt) { + c.cache.Delete(key) + return nil, false + } + return entry.value, true +} + +// cacheSet stores a value in the cache with TTL. +func (c *Client) cacheSet(key string, value any) { + c.cache.Store(key, cacheEntry{ + value: value, + expiresAt: time.Now().Add(c.cacheTTL), + }) +} + +// entryToUser converts an LDAP entry into an LDAPUser struct. +func entryToUser(entry *goldap.Entry) LDAPUser { + user := LDAPUser{ + UID: entry.GetAttributeValue("uid"), + FullName: entry.GetAttributeValue("cn"), + Email: entry.GetAttributeValue("mail"), + Title: entry.GetAttributeValue("title"), + } + for _, socialURL := range entry.GetAttributeValues("rhatSocialURL") { + if gh := ParseGitHubUsername(socialURL); gh != "" { + user.GitHubUsername = gh + break + } + } + for _, dn := range entry.GetAttributeValues("memberOf") { + if cn := extractCNFromDN(dn); cn != "" { + user.Groups = append(user.Groups, cn) + } + } + return user +} + +// SearchUsers searches for users matching the query string. +func (c *Client) SearchUsers(query string) ([]LDAPUser, error) { + query = sanitizeQuery(query) + if len(query) < MinQueryLength { + return nil, nil + } + + cacheKey := cacheKeyUserSearch + query + if cached, ok := c.cacheGet(cacheKey); ok { + if users, ok := cached.([]LDAPUser); ok { + return users, nil + } + } + + conn, err := c.connect() + if err != nil { + return nil, err + } + defer conn.Close() + + searchReq := goldap.NewSearchRequest( + c.baseDN, + goldap.ScopeWholeSubtree, + goldap.NeverDerefAliases, + defaultMaxResults, + defaultQueryTimeoutSec, + false, + UserSearchFilter(query), + userAttributes, + nil, + ) + + result, err := conn.Search(searchReq) + if err != nil && !goldap.IsErrorWithCode(err, goldap.LDAPResultSizeLimitExceeded) { + return nil, fmt.Errorf("ldap user search: %w", err) + } + + users := make([]LDAPUser, 0, len(result.Entries)) + for _, entry := range result.Entries { + users = append(users, entryToUser(entry)) + } + + c.cacheSet(cacheKey, users) + return users, nil +} + +// SearchGroups searches for groups matching the query string. +// Searches the cn attribute with a prefix match in ou=managedGroups. +func (c *Client) SearchGroups(query string) ([]LDAPGroup, error) { + query = sanitizeQuery(query) + if len(query) < MinQueryLength { + return nil, nil + } + + cacheKey := cacheKeyGroupSearch + query + if cached, ok := c.cacheGet(cacheKey); ok { + if groups, ok := cached.([]LDAPGroup); ok { + return groups, nil + } + } + + conn, err := c.connect() + if err != nil { + return nil, err + } + defer conn.Close() + + searchReq := goldap.NewSearchRequest( + c.groupBaseDN, + goldap.ScopeWholeSubtree, + goldap.NeverDerefAliases, + defaultMaxResults, + defaultQueryTimeoutSec, + false, + GroupSearchFilter(query), + []string{"cn", "description"}, + nil, + ) + + result, err := conn.Search(searchReq) + if err != nil && !goldap.IsErrorWithCode(err, goldap.LDAPResultSizeLimitExceeded) { + return nil, fmt.Errorf("ldap group search: %w", err) + } + + groups := make([]LDAPGroup, 0, len(result.Entries)) + for _, entry := range result.Entries { + groups = append(groups, LDAPGroup{ + Name: entry.GetAttributeValue("cn"), + Description: entry.GetAttributeValue("description"), + }) + } + + c.cacheSet(cacheKey, groups) + return groups, nil +} + +// GetUser retrieves a single user by exact UID match. +func (c *Client) GetUser(uid string) (*LDAPUser, error) { + uid = sanitizeQuery(uid) + if uid == "" { + return nil, nil + } + + cacheKey := cacheKeyUser + uid + if cached, ok := c.cacheGet(cacheKey); ok { + if user, ok := cached.(*LDAPUser); ok { + return user, nil + } + } + + conn, err := c.connect() + if err != nil { + return nil, err + } + defer conn.Close() + + escaped := goldap.EscapeFilter(uid) + filter := fmt.Sprintf("(uid=%s)", escaped) + + searchReq := goldap.NewSearchRequest( + c.baseDN, + goldap.ScopeWholeSubtree, + goldap.NeverDerefAliases, + 1, + defaultQueryTimeoutSec, + false, + filter, + userAttributes, + nil, + ) + + result, err := conn.Search(searchReq) + if err != nil { + return nil, fmt.Errorf("ldap user get: %w", err) + } + + if len(result.Entries) == 0 { + return nil, nil + } + + user := entryToUser(result.Entries[0]) + c.cacheSet(cacheKey, &user) + return &user, nil +} + +// ParseGitHubUsername extracts a GitHub username from an rhatSocialURL value. +// Expected format: "Github->https://github.com/" +func ParseGitHubUsername(socialURL string) string { + prefix := "Github->https://github.com/" + if !strings.HasPrefix(socialURL, prefix) { + return "" + } + username := strings.TrimPrefix(socialURL, prefix) + // Remove trailing slashes and any path segments + username = strings.TrimRight(username, "/") + if idx := strings.Index(username, "/"); idx >= 0 { + username = username[:idx] + } + return username +} + +// extractCNFromDN extracts the CN value from a distinguished name. +// e.g. "cn=mygroup,ou=managedGroups,dc=redhat,dc=com" -> "mygroup" +func extractCNFromDN(dn string) string { + parts := strings.Split(dn, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "cn=") { + return part[3:] + } + } + return "" +} + +// sanitizeQuery cleans and truncates a search query. +func sanitizeQuery(q string) string { + q = strings.TrimSpace(q) + q = strings.NewReplacer("\n", "", "\r", "").Replace(q) + if len(q) > maxQueryLength { + q = q[:maxQueryLength] + } + return q +} + +// UserSearchFilter builds the LDAP filter string for user searches. +// Searches uid, givenName (first name), and sn (last name) with substring matching. +// Multi-word queries are split so each word must match at least one field (AND). +// Exported for testing. +func UserSearchFilter(query string) string { + words := strings.Fields(query) + if len(words) == 0 { + return "(uid=*)" + } + if len(words) == 1 { + escaped := goldap.EscapeFilter(words[0]) + return fmt.Sprintf("(|(uid=*%s*)(givenName=*%s*)(sn=*%s*))", escaped, escaped, escaped) + } + // Multiple words: each word must match at least one field + parts := make([]string, 0, len(words)) + for _, w := range words { + escaped := goldap.EscapeFilter(w) + parts = append(parts, fmt.Sprintf("(|(uid=*%s*)(givenName=*%s*)(sn=*%s*))", escaped, escaped, escaped)) + } + return "(&" + strings.Join(parts, "") + ")" +} + +// GroupSearchFilter builds the LDAP filter string for group searches. +// Exported for testing. +func GroupSearchFilter(query string) string { + escaped := goldap.EscapeFilter(query) + return fmt.Sprintf("(cn=%s*)", escaped) +} diff --git a/components/backend/ldap/client_test.go b/components/backend/ldap/client_test.go new file mode 100644 index 000000000..5db07d982 --- /dev/null +++ b/components/backend/ldap/client_test.go @@ -0,0 +1,330 @@ +package ldap + +import ( + "testing" + "time" +) + +func TestParseGitHubUsername(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid github url", + input: "Github->https://github.com/jdoe", + expected: "jdoe", + }, + { + name: "valid github url with trailing slash", + input: "Github->https://github.com/jdoe/", + expected: "jdoe", + }, + { + name: "valid github url with extra path", + input: "Github->https://github.com/jdoe/repos", + expected: "jdoe", + }, + { + name: "non-github social url", + input: "Twitter->https://twitter.com/jdoe", + expected: "", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "partial prefix", + input: "Github->https://github.com/", + expected: "", + }, + { + name: "different case prefix", + input: "github->https://github.com/jdoe", + expected: "", + }, + { + name: "linkedin url", + input: "LinkedIn->https://www.linkedin.com/in/someone", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseGitHubUsername(tt.input) + if result != tt.expected { + t.Errorf("ParseGitHubUsername(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestExtractCNFromDN(t *testing.T) { + tests := []struct { + name string + dn string + expected string + }{ + { + name: "standard group DN", + dn: "cn=aipcc-eng-all,ou=managedGroups,dc=redhat,dc=com", + expected: "aipcc-eng-all", + }, + { + name: "CN with spaces", + dn: "cn=My Group, ou=groups, dc=example, dc=com", + expected: "My Group", + }, + { + name: "no CN", + dn: "ou=managedGroups,dc=redhat,dc=com", + expected: "", + }, + { + name: "empty string", + dn: "", + expected: "", + }, + { + name: "uppercase CN", + dn: "CN=TestGroup,ou=groups,dc=example,dc=com", + expected: "TestGroup", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractCNFromDN(tt.dn) + if result != tt.expected { + t.Errorf("extractCNFromDN(%q) = %q, want %q", tt.dn, result, tt.expected) + } + }) + } +} + +func TestSanitizeQuery(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal query", + input: "jdo", + expected: "jdo", + }, + { + name: "leading/trailing spaces", + input: " jdo ", + expected: "jdo", + }, + { + name: "long query truncated", + input: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz1234567890", + expected: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "embedded newline stripped", + input: "foo\nbar", + expected: "foobar", + }, + { + name: "embedded carriage return stripped", + input: "foo\rbar", + expected: "foobar", + }, + { + name: "CRLF stripped", + input: "foo\r\nbar", + expected: "foobar", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeQuery(tt.input) + if result != tt.expected { + t.Errorf("sanitizeQuery(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestUserSearchFilter(t *testing.T) { + tests := []struct { + name string + query string + expected string + }{ + { + name: "simple query", + query: "jdoe", + expected: "(|(uid=*jdoe*)(givenName=*jdoe*)(sn=*jdoe*))", + }, + { + name: "first name query", + query: "Jane", + expected: "(|(uid=*Jane*)(givenName=*Jane*)(sn=*Jane*))", + }, + { + name: "query with special chars", + query: "user(test)", + expected: `(|(uid=*user\28test\29*)(givenName=*user\28test\29*)(sn=*user\28test\29*))`, + }, + { + name: "query with asterisk", + query: "user*", + expected: `(|(uid=*user\2a*)(givenName=*user\2a*)(sn=*user\2a*))`, + }, + { + name: "multi-word query", + query: "Jane Do", + expected: "(&(|(uid=*Jane*)(givenName=*Jane*)(sn=*Jane*))(|(uid=*Do*)(givenName=*Do*)(sn=*Do*)))", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := UserSearchFilter(tt.query) + if result != tt.expected { + t.Errorf("UserSearchFilter(%q) = %q, want %q", tt.query, result, tt.expected) + } + }) + } +} + +func TestGroupSearchFilter(t *testing.T) { + tests := []struct { + name string + query string + expected string + }{ + { + name: "simple query", + query: "aipcc", + expected: "(cn=aipcc*)", + }, + { + name: "query with special chars", + query: "group(test)", + expected: `(cn=group\28test\29*)`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GroupSearchFilter(tt.query) + if result != tt.expected { + t.Errorf("GroupSearchFilter(%q) = %q, want %q", tt.query, result, tt.expected) + } + }) + } +} + +func TestCacheTTL(t *testing.T) { + client := &Client{ + cacheTTL: 50 * time.Millisecond, + } + + client.cacheSet("test-key", "test-value") + + // Should be in cache + val, ok := client.cacheGet("test-key") + if !ok { + t.Fatal("expected cache hit") + } + if val.(string) != "test-value" { + t.Errorf("expected 'test-value', got %v", val) + } + + // Wait for expiry + time.Sleep(60 * time.Millisecond) + + // Should be expired + _, ok = client.cacheGet("test-key") + if ok { + t.Fatal("expected cache miss after TTL") + } +} + +func TestCacheMiss(t *testing.T) { + client := &Client{ + cacheTTL: defaultCacheTTL, + } + + _, ok := client.cacheGet("nonexistent") + if ok { + t.Fatal("expected cache miss for nonexistent key") + } +} + +func TestNewClient(t *testing.T) { + client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false) + + if client.url != "ldaps://ldap.example.com" { + t.Errorf("expected url 'ldaps://ldap.example.com', got %q", client.url) + } + if client.baseDN != "ou=users,dc=redhat,dc=com" { + t.Errorf("expected baseDN 'ou=users,dc=redhat,dc=com', got %q", client.baseDN) + } + if client.groupBaseDN != "ou=managedGroups,dc=redhat,dc=com" { + t.Errorf("expected groupBaseDN 'ou=managedGroups,dc=redhat,dc=com', got %q", client.groupBaseDN) + } + if client.cacheTTL != defaultCacheTTL { + t.Errorf("expected cacheTTL %v, got %v", defaultCacheTTL, client.cacheTTL) + } +} + +func TestNewClientExplicitGroupBaseDN(t *testing.T) { + client := NewClient("ldaps://ldap.example.com", "ou=users,dc=example,dc=com", "ou=groups,dc=example,dc=com", false) + + if client.groupBaseDN != "ou=groups,dc=example,dc=com" { + t.Errorf("expected explicit groupBaseDN 'ou=groups,dc=example,dc=com', got %q", client.groupBaseDN) + } +} + +func TestSearchUsersShortQuery(t *testing.T) { + client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false) + + // Query too short should return nil without connecting + users, err := client.SearchUsers("m") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if users != nil { + t.Errorf("expected nil for short query, got %v", users) + } +} + +func TestSearchGroupsShortQuery(t *testing.T) { + client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false) + + groups, err := client.SearchGroups("a") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if groups != nil { + t.Errorf("expected nil for short query, got %v", groups) + } +} + +func TestGetUserEmptyUID(t *testing.T) { + client := NewClient("ldaps://ldap.example.com", "ou=users,dc=redhat,dc=com", "", false) + + user, err := client.GetUser("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user != nil { + t.Errorf("expected nil for empty uid, got %v", user) + } +} diff --git a/components/backend/main.go b/components/backend/main.go index 092a458df..b252c3915 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -13,6 +13,7 @@ import ( "ambient-code-backend/github" "ambient-code-backend/handlers" "ambient-code-backend/k8s" + "ambient-code-backend/ldap" "ambient-code-backend/server" "ambient-code-backend/websocket" @@ -132,6 +133,15 @@ func main() { return server.Namespace } + // Initialize LDAP client (optional - enabled when LDAP_URL is set) + if ldapURL := os.Getenv("LDAP_URL"); ldapURL != "" { + ldapBaseDN := getEnvOrDefault("LDAP_BASE_DN", "ou=users,dc=redhat,dc=com") + ldapGroupBaseDN := os.Getenv("LDAP_GROUP_BASE_DN") // optional, derived from LDAP_BASE_DN if empty + skipTLS := os.Getenv("LDAP_SKIP_TLS_VERIFY") == "true" + handlers.LDAPClient = ldap.NewClient(ldapURL, ldapBaseDN, ldapGroupBaseDN, skipTLS) + log.Printf("LDAP client initialized: %s (base DN: %s, group base DN: %s, skipTLSVerify: %v)", ldapURL, ldapBaseDN, ldapGroupBaseDN, skipTLS) + } + // Initialize GitHub auth handlers handlers.K8sClient = server.K8sClient handlers.Namespace = server.Namespace diff --git a/components/backend/routes.go b/components/backend/routes.go index 955e4fe57..40e7f9122 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -150,6 +150,11 @@ func registerRoutes(r *gin.Engine) { // Cluster info endpoint (public, no auth required) api.GET("/cluster-info", handlers.GetClusterInfo) + // LDAP search endpoints (cluster-scoped, auth-required) + api.GET("/ldap/users", handlers.SearchLDAPUsers) + api.GET("/ldap/users/:uid", handlers.GetLDAPUser) + api.GET("/ldap/groups", handlers.SearchLDAPGroups) + api.GET("/projects", handlers.ListProjects) api.POST("/projects", handlers.CreateProject) api.GET("/projects/:projectName", handlers.GetProject) diff --git a/components/frontend/src/app/api/ldap/groups/route.ts b/components/frontend/src/app/api/ldap/groups/route.ts new file mode 100644 index 000000000..3c0a0badc --- /dev/null +++ b/components/frontend/src/app/api/ldap/groups/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + try { + const headers = await buildForwardHeadersAsync(request); + const queryString = request.nextUrl.search; + const response = await fetch(`${BACKEND_URL}/ldap/groups${queryString}`, { + method: 'GET', + headers, + }); + + const data = await response.text(); + + return new NextResponse(data, { + status: response.status, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Failed to search LDAP groups:", error); + return NextResponse.json( + { error: "LDAP search unavailable" }, + { status: 503 } + ); + } +} diff --git a/components/frontend/src/app/api/ldap/users/[uid]/route.ts b/components/frontend/src/app/api/ldap/users/[uid]/route.ts new file mode 100644 index 000000000..e0562ff4b --- /dev/null +++ b/components/frontend/src/app/api/ldap/users/[uid]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ uid: string }> } +) { + try { + const { uid } = await params; + const headers = await buildForwardHeadersAsync(request); + const response = await fetch(`${BACKEND_URL}/ldap/users/${encodeURIComponent(uid)}`, { + method: 'GET', + headers, + }); + + const data = await response.text(); + + return new NextResponse(data, { + status: response.status, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Failed to get LDAP user:", error); + return NextResponse.json( + { error: "Failed to get user" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/api/ldap/users/route.ts b/components/frontend/src/app/api/ldap/users/route.ts new file mode 100644 index 000000000..b9bcf18cd --- /dev/null +++ b/components/frontend/src/app/api/ldap/users/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + try { + const headers = await buildForwardHeadersAsync(request); + const queryString = request.nextUrl.search; + const response = await fetch(`${BACKEND_URL}/ldap/users${queryString}`, { + method: 'GET', + headers, + }); + + const data = await response.text(); + + return new NextResponse(data, { + status: response.status, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Failed to search LDAP users:", error); + return NextResponse.json( + { error: "LDAP search unavailable" }, + { status: 503 } + ); + } +} diff --git a/components/frontend/src/components/workspace-sections/_components/ldap-autocomplete.tsx b/components/frontend/src/components/workspace-sections/_components/ldap-autocomplete.tsx new file mode 100644 index 000000000..68338d2ac --- /dev/null +++ b/components/frontend/src/components/workspace-sections/_components/ldap-autocomplete.tsx @@ -0,0 +1,252 @@ +'use client'; + +import * as React from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { useDebounce } from '@/hooks/use-debounce'; +import { useLDAPUserSearch, useLDAPGroupSearch } from '@/services/queries'; + +type LDAPAutocompleteProps = { + mode: 'user' | 'group'; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + id?: string; + className?: string; +}; + +export function LDAPAutocomplete({ + mode, + value, + onChange, + placeholder, + disabled, + id, + className, +}: LDAPAutocompleteProps) { + const instanceId = useId(); + const debouncedQuery = useDebounce(value.length >= 2 ? value : '', 300); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + const listRef = useRef(null); + const containerRef = useRef(null); + + const { data: users, isLoading: isLoadingUsers } = useLDAPUserSearch( + mode === 'user' ? debouncedQuery : '' + ); + const { data: groups, isLoading: isLoadingGroups } = useLDAPGroupSearch( + mode === 'group' ? debouncedQuery : '' + ); + + const isLoading = mode === 'user' ? isLoadingUsers : isLoadingGroups; + const hasResults = mode === 'user' + ? (users && users.length > 0) + : (groups && groups.length > 0); + const resultCount = mode === 'user' + ? (users?.length ?? 0) + : (groups?.length ?? 0); + + // Show dropdown when we have a query and results + useEffect(() => { + if (debouncedQuery.length >= 2 && (hasResults || isLoading)) { + setIsOpen(true); + } else if (debouncedQuery.length < 2) { + setIsOpen(false); + } + }, [debouncedQuery, hasResults, isLoading]); + + // Reset highlight when results change + useEffect(() => { + setHighlightedIndex(-1); + }, [users, groups]); + + const selectItem = useCallback( + (identifier: string) => { + onChange(identifier); + setIsOpen(false); + }, + [onChange] + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + }, + [onChange] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isOpen) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex((prev) => (prev < resultCount - 1 ? prev + 1 : 0)); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : resultCount - 1)); + break; + case 'Enter': + e.preventDefault(); + if (highlightedIndex >= 0) { + if (mode === 'user' && users?.[highlightedIndex]) { + selectItem(users[highlightedIndex].uid); + } else if (mode === 'group' && groups?.[highlightedIndex]) { + selectItem(groups[highlightedIndex].name); + } + } + break; + case 'Escape': + setIsOpen(false); + break; + } + }, + [isOpen, highlightedIndex, resultCount, mode, users, groups, selectItem] + ); + + // Close dropdown on click outside (only when open) + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && listRef.current) { + const items = listRef.current.querySelectorAll('[role="option"]'); + if (items[highlightedIndex]) { + items[highlightedIndex].scrollIntoView({ block: 'nearest' }); + } + } + }, [highlightedIndex]); + + const listId = `${instanceId}-ldap-list`; + + return ( +
+
+ { + if (debouncedQuery.length >= 2 && hasResults) { + setIsOpen(true); + } + }} + placeholder={placeholder} + disabled={disabled} + className={className} + role="combobox" + aria-expanded={isOpen} + aria-autocomplete="list" + aria-controls={isOpen ? listId : undefined} + aria-activedescendant={ + highlightedIndex >= 0 ? `${instanceId}-option-${highlightedIndex}` : undefined + } + autoComplete="off" + /> + {isLoading && debouncedQuery.length >= 2 && ( +
+ +
+ )} +
+ + {isOpen && ( +
    + {isLoading && ( +
  • + + Searching... +
  • + )} + + {!isLoading && !hasResults && debouncedQuery.length >= 2 && ( +
  • No results found
  • + )} + + {mode === 'user' && + users?.map((user, index) => ( +
  • { + e.preventDefault(); + selectItem(user.uid); + }} + onMouseEnter={() => setHighlightedIndex(index)} + > +
    + {user.fullName} + - {user.uid} +
    + {user.title && ( +
    {user.title}
    + )} +
  • + ))} + + {mode === 'group' && + groups?.map((group, index) => ( +
  • { + e.preventDefault(); + selectItem(group.name); + }} + onMouseEnter={() => setHighlightedIndex(index)} + > +
    {group.name}
    + {group.description && ( +
    {group.description}
    + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/components/frontend/src/components/workspace-sections/sharing-section.tsx b/components/frontend/src/components/workspace-sections/sharing-section.tsx index b005a511e..c22b43121 100644 --- a/components/frontend/src/components/workspace-sections/sharing-section.tsx +++ b/components/frontend/src/components/workspace-sections/sharing-section.tsx @@ -6,7 +6,7 @@ import { Users, User as UserIcon, Plus, RefreshCw, Loader2, Trash2, Info } from import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; +import { LDAPAutocomplete } from './_components/ldap-autocomplete'; import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; @@ -242,7 +242,7 @@ export function SharingSection({ projectName }: SharingSectionProps) { value={grantForm.subjectType} onValueChange={(value) => { if (addPermissionMutation.isPending) return; - setGrantForm((prev) => ({ ...prev, subjectType: value as SubjectType })); + setGrantForm((prev) => ({ ...prev, subjectType: value as SubjectType, subjectName: '' })); }} > @@ -255,11 +255,12 @@ export function SharingSection({ projectName }: SharingSectionProps) { - setGrantForm((prev) => ({ ...prev, subjectName: e.target.value }))} + onChange={(val) => setGrantForm((prev) => ({ ...prev, subjectName: val }))} disabled={addPermissionMutation.isPending} /> diff --git a/components/frontend/src/services/api/index.ts b/components/frontend/src/services/api/index.ts index 4c027e76a..7d480f63d 100644 --- a/components/frontend/src/services/api/index.ts +++ b/components/frontend/src/services/api/index.ts @@ -12,3 +12,4 @@ export * as keysApi from './keys'; export * as repoApi from './repo'; export * as workspaceApi from './workspace'; export * as authApi from './auth'; +export * as ldapApi from './ldap'; diff --git a/components/frontend/src/services/api/ldap.ts b/components/frontend/src/services/api/ldap.ts new file mode 100644 index 000000000..9e41dce67 --- /dev/null +++ b/components/frontend/src/services/api/ldap.ts @@ -0,0 +1,55 @@ +/** + * API service for LDAP user and group search + */ + +import { apiClient } from './client'; + +// Types +export type LDAPUser = { + uid: string; + fullName: string; + email: string; + title: string; + githubUsername: string; + groups: string[]; +}; + +export type LDAPGroup = { + name: string; + description: string; +}; + +type SearchUsersResponse = { + users: LDAPUser[]; +}; + +type SearchGroupsResponse = { + groups: LDAPGroup[]; +}; + +/** + * Search LDAP users by query (min 2 chars) + */ +export async function searchUsers(query: string): Promise { + const response = await apiClient.get('/ldap/users', { + params: { q: query }, + }); + return response.users || []; +} + +/** + * Search LDAP groups by query (min 2 chars) + */ +export async function searchGroups(query: string): Promise { + const response = await apiClient.get('/ldap/groups', { + params: { q: query }, + }); + return response.groups || []; +} + +/** + * Get a single LDAP user by UID + */ +export async function getUser(uid: string): Promise { + return apiClient.get(`/ldap/users/${encodeURIComponent(uid)}`); +} diff --git a/components/frontend/src/services/queries/index.ts b/components/frontend/src/services/queries/index.ts index d91d75b02..71ba6b7b9 100644 --- a/components/frontend/src/services/queries/index.ts +++ b/components/frontend/src/services/queries/index.ts @@ -16,3 +16,4 @@ export * from './use-google'; export * from './use-feature-flags-admin'; export * from './use-capabilities'; export * from './use-runner-types'; +export * from './use-ldap'; diff --git a/components/frontend/src/services/queries/use-ldap.ts b/components/frontend/src/services/queries/use-ldap.ts new file mode 100644 index 000000000..258abd3ef --- /dev/null +++ b/components/frontend/src/services/queries/use-ldap.ts @@ -0,0 +1,54 @@ +/** + * React Query hooks for LDAP user and group search + */ + +import { useQuery } from '@tanstack/react-query'; +import * as ldapApi from '../api/ldap'; + +// Query key factory +export const ldapKeys = { + all: ['ldap'] as const, + users: () => [...ldapKeys.all, 'users'] as const, + userSearch: (query: string) => [...ldapKeys.users(), 'search', query] as const, + user: (uid: string) => [...ldapKeys.users(), uid] as const, + groups: () => [...ldapKeys.all, 'groups'] as const, + groupSearch: (query: string) => [...ldapKeys.groups(), 'search', query] as const, +}; + +/** + * Hook to search LDAP users with autocomplete + * Only fires when query is >= 2 characters + */ +export function useLDAPUserSearch(query: string) { + return useQuery({ + queryKey: ldapKeys.userSearch(query), + queryFn: () => ldapApi.searchUsers(query), + enabled: query.length >= 2, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Hook to search LDAP groups with autocomplete + * Only fires when query is >= 2 characters + */ +export function useLDAPGroupSearch(query: string) { + return useQuery({ + queryKey: ldapKeys.groupSearch(query), + queryFn: () => ldapApi.searchGroups(query), + enabled: query.length >= 2, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Hook to get a single LDAP user by UID + */ +export function useLDAPUser(uid: string) { + return useQuery({ + queryKey: ldapKeys.user(uid), + queryFn: () => ldapApi.getUser(uid), + enabled: !!uid, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/components/manifests/base/backend-deployment.yaml b/components/manifests/base/backend-deployment.yaml index 18626c8e0..a18593279 100644 --- a/components/manifests/base/backend-deployment.yaml +++ b/components/manifests/base/backend-deployment.yaml @@ -129,6 +129,31 @@ spec: configMapKeyRef: name: operator-config key: GOOGLE_APPLICATION_CREDENTIALS + # LDAP configuration (optional - autocomplete disabled when not set) + - name: LDAP_URL + valueFrom: + configMapKeyRef: + name: ldap-config + key: LDAP_URL + optional: true + - name: LDAP_BASE_DN + valueFrom: + configMapKeyRef: + name: ldap-config + key: LDAP_BASE_DN + optional: true + - name: LDAP_GROUP_BASE_DN + valueFrom: + configMapKeyRef: + name: ldap-config + key: LDAP_GROUP_BASE_DN + optional: true + - name: LDAP_SKIP_TLS_VERIFY + valueFrom: + configMapKeyRef: + name: ldap-config + key: LDAP_SKIP_TLS_VERIFY + optional: true # Unleash feature flags (optional - all flags disabled when not set) - name: UNLEASH_URL valueFrom: diff --git a/components/manifests/overlays/kind/kustomization.yaml b/components/manifests/overlays/kind/kustomization.yaml index e865b2180..c3cb764ba 100644 --- a/components/manifests/overlays/kind/kustomization.yaml +++ b/components/manifests/overlays/kind/kustomization.yaml @@ -13,6 +13,7 @@ resources: - minio-credentials.yaml - postgresql-credentials.yaml - unleash-credentials.yaml +- ldap-config.yaml # PostgreSQL init scripts for database creation (kind only) - postgresql-init-scripts.yaml diff --git a/components/manifests/overlays/kind/ldap-config.yaml b/components/manifests/overlays/kind/ldap-config.yaml new file mode 100644 index 000000000..a1bec98fa --- /dev/null +++ b/components/manifests/overlays/kind/ldap-config.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ldap-config + labels: + app: backend-api +data: + LDAP_URL: "ldaps://ldap.corp.redhat.com" + LDAP_BASE_DN: "ou=users,dc=redhat,dc=com" + LDAP_SKIP_TLS_VERIFY: "true" diff --git a/components/manifests/overlays/local-dev/kustomization.yaml b/components/manifests/overlays/local-dev/kustomization.yaml index 10e488f7e..5aea8b560 100644 --- a/components/manifests/overlays/local-dev/kustomization.yaml +++ b/components/manifests/overlays/local-dev/kustomization.yaml @@ -13,6 +13,7 @@ resources: - backend-route.yaml - frontend-route.yaml - operator-config-crc.yaml +- ldap-config.yaml - unleash-credentials.yaml - unleash-route.yaml diff --git a/components/manifests/overlays/local-dev/ldap-config.yaml b/components/manifests/overlays/local-dev/ldap-config.yaml new file mode 100644 index 000000000..a1bec98fa --- /dev/null +++ b/components/manifests/overlays/local-dev/ldap-config.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ldap-config + labels: + app: backend-api +data: + LDAP_URL: "ldaps://ldap.corp.redhat.com" + LDAP_BASE_DN: "ou=users,dc=redhat,dc=com" + LDAP_SKIP_TLS_VERIFY: "true" diff --git a/components/manifests/overlays/production/kustomization.yaml b/components/manifests/overlays/production/kustomization.yaml index 07b5829ef..0238606b8 100644 --- a/components/manifests/overlays/production/kustomization.yaml +++ b/components/manifests/overlays/production/kustomization.yaml @@ -18,6 +18,7 @@ resources: - public-api-route.yaml - unleash-route.yaml - operator-config-openshift.yaml +- ldap-config.yaml # Patches for production environment # Unleash: init container to create database (RHEL doesn't support init scripts) diff --git a/components/manifests/overlays/production/ldap-config.yaml b/components/manifests/overlays/production/ldap-config.yaml new file mode 100644 index 000000000..a1bec98fa --- /dev/null +++ b/components/manifests/overlays/production/ldap-config.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ldap-config + labels: + app: backend-api +data: + LDAP_URL: "ldaps://ldap.corp.redhat.com" + LDAP_BASE_DN: "ou=users,dc=redhat,dc=com" + LDAP_SKIP_TLS_VERIFY: "true"