diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index 07e723c..f65eba5 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -475,12 +475,14 @@ func (s *Server) SetTenantLDAP(store *ldapcfg.TenantStore) { s.mux.HandleFunc("PUT /api/tenant/ldap", s.authAdmin(s.handleSaveTenantLDAP)) s.mux.HandleFunc("DELETE /api/tenant/ldap", s.authAdmin(s.handleDeleteTenantLDAP)) s.mux.HandleFunc("POST /api/tenant/ldap/test", s.authAdmin(s.handleTestTenantLDAP)) + s.mux.HandleFunc("POST /api/tenant/ldap/sync", s.authAdmin(s.handleSyncTenantLDAP)) // superadmin routes — tenant_id from URL parameter s.mux.HandleFunc("GET /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminGetTenantLDAP))) s.mux.HandleFunc("PUT /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminSaveTenantLDAP))) s.mux.HandleFunc("DELETE /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminDeleteTenantLDAP))) s.mux.HandleFunc("POST /api/admin/tenants/{id}/ldap/test", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminTestTenantLDAP))) + s.mux.HandleFunc("POST /api/admin/tenants/{id}/ldap/sync", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminSyncTenantLDAP))) } // ── domain_admin handlers (own tenant) ────────────────────────────────────── @@ -769,6 +771,102 @@ func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, result) } +// ── LDAP Sync handlers ─────────────────────────────────────────────────────── + +type syncResult struct { + Synced int `json:"synced"` + Errors []string `json:"errors"` +} + +// handleSyncTenantLDAP imports all LDAP users into the tenant (domain_admin). +func (s *Server) handleSyncTenantLDAP(w http.ResponseWriter, r *http.Request) { + if s.tenantLdapStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") + return + } + sess := sessionFromCtx(r.Context()) + if sess.TenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + res := s.doSyncTenantLDAP(r, *sess.TenantID) + s.audlog.Log(audit.Entry{ + EventType: "tenant_ldap_sync", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: len(res.Errors) == 0, + Detail: fmt.Sprintf("%d Benutzer synchronisiert", res.Synced), + }) + writeJSON(w, http.StatusOK, res) +} + +// handleAdminSyncTenantLDAP imports all LDAP users into any tenant (superadmin). +func (s *Server) handleAdminSyncTenantLDAP(w http.ResponseWriter, r *http.Request) { + if s.tenantLdapStore == nil { + writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") + return + } + id, err := parseTenantID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid tenant id") + return + } + res := s.doSyncTenantLDAP(r, id) + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: "tenant_ldap_sync", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: len(res.Errors) == 0, + Detail: fmt.Sprintf("%d Benutzer synchronisiert (tenant %d)", res.Synced, id), + }) + writeJSON(w, http.StatusOK, res) +} + +// doSyncTenantLDAP is the shared sync logic: fetch all LDAP users and upsert +// them into the local userstore with source='ldap'. +func (s *Server) doSyncTenantLDAP(r *http.Request, tenantID int64) syncResult { + saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID) + if err != nil || saved == nil { + return syncResult{Errors: []string{"keine LDAP-Konfiguration vorhanden"}} + } + + cfg := ldapauth.Config{ + URL: saved.URL, + BindDN: saved.BindDN, + BindPassword: saved.BindPassword, + BaseDN: saved.BaseDN, + UserFilter: saved.UserFilter, + TLS: saved.TLS, + TLSSkipVerify: saved.TLSSkipVerify, + } + + ldapUsers, err := ldapauth.FetchUsers(cfg) + if err != nil { + return syncResult{Errors: []string{err.Error()}} + } + + defaultRole := saved.DefaultRole + if defaultRole == "" { + defaultRole = "user" + } + + var res syncResult + res.Errors = []string{} + for _, u := range ldapUsers { + mail := u.Mail + if mail == "" { + mail = u.UID + "@ldap.local" + } + if _, uErr := s.users.UpsertLDAPUser(u.UID, mail, defaultRole, &tenantID); uErr != nil { + res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", u.UID, uErr.Error())) + } else { + res.Synced++ + } + } + return res +} + // buildTenantTestConfig constructs an ldapauth.Config for testing, either from // the saved tenant config or from the provided request body. func (s *Server) buildTenantTestConfig(r *http.Request, useSaved bool, tenantID int64, provided ldapcfg.TenantLDAPConfig) *ldapauth.Config { diff --git a/internal/ldapauth/client.go b/internal/ldapauth/client.go index cf6c5a5..dc4df5f 100644 --- a/internal/ldapauth/client.go +++ b/internal/ldapauth/client.go @@ -186,6 +186,66 @@ func queryRootDSE(conn *ldapv3.Conn) string { return strings.Join(parts, " ") } +// FetchUsers connects to LDAP and returns all user objects (up to 2000) for +// bulk import / synchronisation. Unlike TestConnection it does not cap the +// preview at 50 entries. +func FetchUsers(cfg Config) ([]LDAPUser, error) { + conn, err := dial(cfg) + if err != nil { + return nil, fmt.Errorf("ldapauth: dial: %w", err) + } + defer conn.Close() + + if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { + return nil, fmt.Errorf("ldapauth: service bind: %w", err) + } + + filter := cfg.UserFilter + if filter == "" { + filter = "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + } else { + // If the UserFilter is a login filter like (uid=%s), make it a wildcard search. + filter = strings.ReplaceAll(filter, "%s", "*") + } + + req := ldapv3.NewSearchRequest( + cfg.BaseDN, + ldapv3.ScopeWholeSubtree, + ldapv3.NeverDerefAliases, + 2000, 60, false, + filter, + []string{"dn", "uid", "cn", "displayName", "mail", "mailPrimaryAddress"}, + nil, + ) + res, err := conn.Search(req) + if err != nil { + return nil, fmt.Errorf("ldapauth: search users: %w", err) + } + + users := make([]LDAPUser, 0, len(res.Entries)) + for _, e := range res.Entries { + mail := e.GetAttributeValue("mail") + if mail == "" { + mail = e.GetAttributeValue("mailPrimaryAddress") + } + displayName := e.GetAttributeValue("displayName") + if displayName == "" { + displayName = e.GetAttributeValue("cn") + } + uid := e.GetAttributeValue("uid") + if uid == "" { + continue // skip entries without a uid + } + users = append(users, LDAPUser{ + DN: e.DN, + UID: uid, + DisplayName: displayName, + Mail: mail, + }) + } + 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. diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a47bce6..16b95aa 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -39,6 +39,8 @@ import { saveAdminTenantLDAPConfig, deleteAdminTenantLDAPConfig, testAdminTenantLDAPConfig, + syncAdminTenantLDAP, + type LDAPSyncResult, getAdminLabels, createAdminLabel, deleteAdminLabel, @@ -266,6 +268,8 @@ export default function AdminPage() { const [tenantUsers, setTenantUsers] = useState([]); const [tenantUsersLoading, setTenantUsersLoading] = useState(false); const [tenantUsersError, setTenantUsersError] = useState(""); + const [tenantUsersSyncing, setTenantUsersSyncing] = useState(false); + const [tenantUsersSyncResult, setTenantUsersSyncResult] = useState(null); // Labels state const [adminLabels, setAdminLabels] = useState([]); @@ -786,6 +790,7 @@ export default function AdminPage() { setTenantUsersLoading(true); setTenantUsers([]); setTenantUsersError(""); + setTenantUsersSyncResult(null); try { const users = await getTenantUsers(t.id); setTenantUsers(users || []); @@ -796,6 +801,23 @@ export default function AdminPage() { } } + async function handleSyncLDAPUsers() { + if (!tenantUsersDialogId) return; + setTenantUsersSyncing(true); + setTenantUsersSyncResult(null); + try { + const result = await syncAdminTenantLDAP(tenantUsersDialogId); + setTenantUsersSyncResult(result); + // Reload user list after sync + const users = await getTenantUsers(tenantUsersDialogId); + setTenantUsers(users || []); + } catch (err: unknown) { + setTenantUsersSyncResult({ synced: 0, errors: [err instanceof Error ? err.message : "Sync fehlgeschlagen"] }); + } finally { + setTenantUsersSyncing(false); + } + } + async function handleAddDomain() { if (!domainDialogTenant || !newDomain) return; setAddDomainLoading(true); @@ -2889,7 +2911,7 @@ export default function AdminPage() {

Keine lokalen Benutzer diesem Mandanten zugewiesen.

{tenantUsersDialogLdap && ( -

LDAP ist aktiv — Benutzer erscheinen hier nach ihrem ersten Login.

+

LDAP ist aktiv — Benutzer erscheinen hier nach ihrem ersten Login oder nach der Synchronisation.

)}
) : ( @@ -2899,6 +2921,7 @@ export default function AdminPage() { Benutzername E-Mail Rolle + Quelle Status @@ -2908,6 +2931,7 @@ export default function AdminPage() { {u.username} {u.email} {u.role} + {u.source || "local"} {u.active ? "Aktiv" : "Inaktiv"} @@ -2918,6 +2942,31 @@ export default function AdminPage() { )} + {tenantUsersDialogLdap && ( +
+
+ + {tenantUsersSyncResult && ( + + {tenantUsersSyncResult.synced} Benutzer synchronisiert + {tenantUsersSyncResult.errors.length > 0 && ( + ({tenantUsersSyncResult.errors.length} Fehler) + )} + + )} +
+ {tenantUsersSyncResult?.errors?.length > 0 && ( +

{tenantUsersSyncResult.errors.join(", ")}

+ )} +
+ )} diff --git a/src/lib/api.ts b/src/lib/api.ts index 008d253..840dc76 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -48,6 +48,7 @@ export interface User { username: string; email: string; role: string; + source?: string; active: boolean; tenant_id?: number; } @@ -879,6 +880,15 @@ export async function testAdminTenantLDAPConfig( }); } +export interface LDAPSyncResult { + synced: number; + errors: string[]; +} + +export async function syncAdminTenantLDAP(tenantID: number): Promise { + return request(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" }); +} + // ── Profil-Einstellungen ────────────────────────────────────────────────── export async function changePassword(