From e0f6a818eb0df10464b6c53d0c78f185bf1188fc Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 20 Mar 2026 14:50:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20LDAP-Test=20zeigt=20klickbare=20Filter-?= =?UTF-8?q?Vorschl=C3=A4ge=20(UCS/AD-Erkennung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach erfolgreichem Verbindungstest werden passende Filter-Vorschläge angezeigt. Erkennt automatisch Univention UCS (posixAccount) vs. Active Directory (sAMAccountName). Klick übernimmt den Filter direkt ins Formular. Vorschläge berücksichtigen mailPrimaryAddress (UCS). Co-Authored-By: Claude Sonnet 4.6 --- internal/ldapauth/client.go | 152 +++++++++++++++++--- src/components/admin/tabs/LDAPTab.tsx | 23 ++- src/components/admin/tabs/TenantLDAPTab.tsx | 23 ++- src/lib/api/index.ts | 1 + src/lib/api/ldap.ts | 8 ++ 5 files changed, 183 insertions(+), 24 deletions(-) diff --git a/internal/ldapauth/client.go b/internal/ldapauth/client.go index 1174fc1..15499fd 100644 --- a/internal/ldapauth/client.go +++ b/internal/ldapauth/client.go @@ -31,15 +31,24 @@ type LDAPUser struct { Mail string `json:"mail"` } +// FilterSuggestion is a clickable filter preset shown after a successful test. +type FilterSuggestion struct { + Label string `json:"label"` + Filter string `json:"filter"` + Description string `json:"description"` +} + // TestResult is the structured output of TestConnection. type TestResult struct { - OK bool `json:"ok"` - Message string `json:"message"` - LatencyMS int64 `json:"latency_ms"` - ServerInfo string `json:"server_info"` - UsersFound int `json:"users_found"` - Users []LDAPUser `json:"users"` - ErrorDetail string `json:"error_detail"` + OK bool `json:"ok"` + Message string `json:"message"` + LatencyMS int64 `json:"latency_ms"` + ServerInfo string `json:"server_info"` + UsersFound int `json:"users_found"` + Users []LDAPUser `json:"users"` + ObjectClasses []string `json:"object_classes"` + FilterSuggestions []FilterSuggestion `json:"filter_suggestions"` + ErrorDetail string `json:"error_detail"` } // TestConnection opens a connection to the LDAP server, binds with the service @@ -72,15 +81,17 @@ func TestConnection(cfg Config) TestResult { serverInfo := queryRootDSE(conn) // Fetch user objects (capped at 500 for count, 50 for preview list) - usersFound, users := listUsers(conn, cfg.BaseDN) + usersFound, users, objectClasses := listUsers(conn, cfg.BaseDN) return TestResult{ - OK: true, - Message: "Verbindung erfolgreich", - LatencyMS: time.Since(start).Milliseconds(), - ServerInfo: serverInfo, - UsersFound: usersFound, - Users: users, + OK: true, + Message: "Verbindung erfolgreich", + LatencyMS: time.Since(start).Milliseconds(), + ServerInfo: serverInfo, + UsersFound: usersFound, + Users: users, + ObjectClasses: objectClasses, + FilterSuggestions: generateFilterSuggestions(objectClasses, users), } } @@ -250,22 +261,33 @@ func FetchUsers(cfg Config) ([]LDAPUser, error) { return users, nil } -// listUsers searches for person/user objects and returns the total count (max 500) -// plus a preview slice of up to 50 entries. Supports both AD (mail) and -// Univention UCS (mailPrimaryAddress) mail attributes. -func listUsers(conn *ldapv3.Conn, baseDN string) (int, []LDAPUser) { +// listUsers searches for person/user objects and returns the total count (max 500), +// a preview slice of up to 50 entries, and the distinct objectClass values found. +func listUsers(conn *ldapv3.Conn, baseDN string) (int, []LDAPUser, []string) { req := ldapv3.NewSearchRequest( baseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 500, 30, false, "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))", - []string{"dn", "uid", "cn", "displayName", "mail", "mailPrimaryAddress"}, + []string{"dn", "uid", "cn", "displayName", "mail", "mailPrimaryAddress", "objectClass", "sAMAccountName"}, nil, ) res, err := conn.Search(req) if err != nil { - return 0, nil + return 0, nil, nil + } + + // Collect distinct objectClass values across all entries. + ocSet := map[string]struct{}{} + for _, e := range res.Entries { + for _, oc := range e.GetAttributeValues("objectClass") { + ocSet[oc] = struct{}{} + } + } + objectClasses := make([]string, 0, len(ocSet)) + for oc := range ocSet { + objectClasses = append(objectClasses, oc) } preview := make([]LDAPUser, 0, min(50, len(res.Entries))) @@ -281,13 +303,99 @@ func listUsers(conn *ldapv3.Conn, baseDN string) (int, []LDAPUser) { if displayName == "" { displayName = e.GetAttributeValue("cn") } + uid := e.GetAttributeValue("uid") + if uid == "" { + uid = e.GetAttributeValue("sAMAccountName") + } preview = append(preview, LDAPUser{ DN: e.DN, - UID: e.GetAttributeValue("uid"), + UID: uid, DisplayName: displayName, Mail: mail, }) } - return len(res.Entries), preview + return len(res.Entries), preview, objectClasses +} + +// generateFilterSuggestions builds clickable filter presets based on the +// objectClasses and user attributes found in the directory. +func generateFilterSuggestions(objectClasses []string, users []LDAPUser) []FilterSuggestion { + ocSet := map[string]bool{} + for _, oc := range objectClasses { + ocSet[strings.ToLower(oc)] = true + } + + hasMailPrimary := false + for _, u := range users { + if strings.Contains(u.Mail, "@") { + hasMailPrimary = true + break + } + } + + isUCS := ocSet["posixaccount"] || ocSet["shadowaccount"] + isAD := ocSet["user"] && !isUCS + + var s []FilterSuggestion + + if isUCS { + s = append(s, + FilterSuggestion{ + Label: "UCS: Login per uid", + Filter: "(uid=%s)", + Description: "Standard-Login-Filter für Univention UCS", + }, + ) + if hasMailPrimary { + s = append(s, + FilterSuggestion{ + Label: "UCS: Nur Benutzer mit Postfach", + Filter: "(&(uid=%s)(mailPrimaryAddress=*))", + Description: "Nur UCS-Benutzer mit aktiviertem Postfach (mailPrimaryAddress)", + }, + ) + } + s = append(s, + FilterSuggestion{ + Label: "UCS: Personen ohne Systemkonten", + Filter: "(&(uid=%s)(!(uid=*$)))", + Description: "Schließt Computerkonten (uid endet auf $) aus", + }, + ) + } + + if isAD { + s = append(s, + FilterSuggestion{ + Label: "AD: Login per sAMAccountName", + Filter: "(sAMAccountName=%s)", + Description: "Standard-Login-Filter für Active Directory", + }, + ) + s = append(s, + FilterSuggestion{ + Label: "AD: Nur Benutzer mit E-Mail", + Filter: "(&(sAMAccountName=%s)(mail=*))", + Description: "AD-Benutzer mit gesetzter E-Mail-Adresse", + }, + ) + s = append(s, + FilterSuggestion{ + Label: "AD: Keine Computerkonten", + Filter: "(&(sAMAccountName=%s)(!(objectClass=computer)))", + Description: "Schließt Computerkonten aus", + }, + ) + } + + if !isUCS && !isAD { + s = append(s, + FilterSuggestion{Label: "uid", Filter: "(uid=%s)", Description: "Login per uid-Attribut"}, + FilterSuggestion{Label: "sAMAccountName", Filter: "(sAMAccountName=%s)", Description: "Login per sAMAccountName (Active Directory)"}, + FilterSuggestion{Label: "mail", Filter: "(mail=%s)", Description: "Login per E-Mail-Adresse"}, + ) + } + + return s } diff --git a/src/components/admin/tabs/LDAPTab.tsx b/src/components/admin/tabs/LDAPTab.tsx index 0055305..7f50712 100644 --- a/src/components/admin/tabs/LDAPTab.tsx +++ b/src/components/admin/tabs/LDAPTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { type LDAPConfig, type LDAPTestResult } from "@/lib/api"; +import { type LDAPConfig, type LDAPTestResult, type LDAPFilterSuggestion } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -303,6 +303,27 @@ export function LDAPTab({ )} + {ldapTestResult.ok && ldapTestResult.filter_suggestions?.length > 0 && ( +
+

Filter-Vorschläge — klicken zum Übernehmen:

+
+ {ldapTestResult.filter_suggestions.map((s: LDAPFilterSuggestion, i: number) => ( + + ))} +
+

+ %s wird beim Login durch den Benutzernamen ersetzt +

+
+ )} )} diff --git a/src/components/admin/tabs/TenantLDAPTab.tsx b/src/components/admin/tabs/TenantLDAPTab.tsx index 414d04b..82b90fe 100644 --- a/src/components/admin/tabs/TenantLDAPTab.tsx +++ b/src/components/admin/tabs/TenantLDAPTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { type TenantLDAPConfig, type LDAPTestResult } from "@/lib/api"; +import { type TenantLDAPConfig, type LDAPTestResult, type LDAPFilterSuggestion } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -314,6 +314,27 @@ export function TenantLDAPTab({ )} + {tenantLdapTestResult.ok && tenantLdapTestResult.filter_suggestions?.length > 0 && ( +
+

Filter-Vorschläge — klicken zum Übernehmen:

+
+ {tenantLdapTestResult.filter_suggestions.map((s: LDAPFilterSuggestion, i: number) => ( + + ))} +
+

+ %s wird beim Login durch den Benutzernamen ersetzt +

+
+ )} )} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5c5b006..7b29d36 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -30,6 +30,7 @@ export type { LDAPConfig, LDAPTestUser, LDAPTestResult, + LDAPFilterSuggestion, } from "./ldap"; export { getLDAPConfig, diff --git a/src/lib/api/ldap.ts b/src/lib/api/ldap.ts index bd73bf0..42bfcb9 100644 --- a/src/lib/api/ldap.ts +++ b/src/lib/api/ldap.ts @@ -30,6 +30,12 @@ export interface LDAPTestUser { mail: string; } +export interface LDAPFilterSuggestion { + label: string; + filter: string; + description: string; +} + export interface LDAPTestResult { ok: boolean; message: string; @@ -37,6 +43,8 @@ export interface LDAPTestResult { server_info: string; users_found: number; users: LDAPTestUser[]; + object_classes: string[]; + filter_suggestions: LDAPFilterSuggestion[]; error_detail: string; }