feat: LDAP-User-Sync in Mandanten-Benutzerliste
Neuer Endpoint POST /api/admin/tenants/{id}/ldap/sync importiert alle
LDAP-User (source=ldap) per UpsertLDAPUser in die Tenant-Benutzerliste.
Im Nutzer-Dialog erscheint ein "LDAP-Benutzer synchronisieren"-Button
wenn LDAP für den Mandanten aktiv ist. Unterstützt Univention UCS
(mailPrimaryAddress, inetOrgPerson). Benutzertabelle zeigt jetzt auch
die Quelle (local/ldap).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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("PUT /api/tenant/ldap", s.authAdmin(s.handleSaveTenantLDAP))
|
||||||
s.mux.HandleFunc("DELETE /api/tenant/ldap", s.authAdmin(s.handleDeleteTenantLDAP))
|
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/test", s.authAdmin(s.handleTestTenantLDAP))
|
||||||
|
s.mux.HandleFunc("POST /api/tenant/ldap/sync", s.authAdmin(s.handleSyncTenantLDAP))
|
||||||
|
|
||||||
// superadmin routes — tenant_id from URL parameter
|
// 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("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("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("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/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) ──────────────────────────────────────
|
// ── domain_admin handlers (own tenant) ──────────────────────────────────────
|
||||||
@@ -769,6 +771,102 @@ func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Reques
|
|||||||
writeJSON(w, http.StatusOK, result)
|
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
|
// buildTenantTestConfig constructs an ldapauth.Config for testing, either from
|
||||||
// the saved tenant config or from the provided request body.
|
// 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 {
|
func (s *Server) buildTenantTestConfig(r *http.Request, useSaved bool, tenantID int64, provided ldapcfg.TenantLDAPConfig) *ldapauth.Config {
|
||||||
|
|||||||
@@ -186,6 +186,66 @@ func queryRootDSE(conn *ldapv3.Conn) string {
|
|||||||
return strings.Join(parts, " ")
|
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)
|
// 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
|
// plus a preview slice of up to 50 entries. Supports both AD (mail) and
|
||||||
// Univention UCS (mailPrimaryAddress) mail attributes.
|
// Univention UCS (mailPrimaryAddress) mail attributes.
|
||||||
|
|||||||
+50
-1
@@ -39,6 +39,8 @@ import {
|
|||||||
saveAdminTenantLDAPConfig,
|
saveAdminTenantLDAPConfig,
|
||||||
deleteAdminTenantLDAPConfig,
|
deleteAdminTenantLDAPConfig,
|
||||||
testAdminTenantLDAPConfig,
|
testAdminTenantLDAPConfig,
|
||||||
|
syncAdminTenantLDAP,
|
||||||
|
type LDAPSyncResult,
|
||||||
getAdminLabels,
|
getAdminLabels,
|
||||||
createAdminLabel,
|
createAdminLabel,
|
||||||
deleteAdminLabel,
|
deleteAdminLabel,
|
||||||
@@ -266,6 +268,8 @@ export default function AdminPage() {
|
|||||||
const [tenantUsers, setTenantUsers] = useState<User[]>([]);
|
const [tenantUsers, setTenantUsers] = useState<User[]>([]);
|
||||||
const [tenantUsersLoading, setTenantUsersLoading] = useState(false);
|
const [tenantUsersLoading, setTenantUsersLoading] = useState(false);
|
||||||
const [tenantUsersError, setTenantUsersError] = useState("");
|
const [tenantUsersError, setTenantUsersError] = useState("");
|
||||||
|
const [tenantUsersSyncing, setTenantUsersSyncing] = useState(false);
|
||||||
|
const [tenantUsersSyncResult, setTenantUsersSyncResult] = useState<LDAPSyncResult | null>(null);
|
||||||
|
|
||||||
// Labels state
|
// Labels state
|
||||||
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
|
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
|
||||||
@@ -786,6 +790,7 @@ export default function AdminPage() {
|
|||||||
setTenantUsersLoading(true);
|
setTenantUsersLoading(true);
|
||||||
setTenantUsers([]);
|
setTenantUsers([]);
|
||||||
setTenantUsersError("");
|
setTenantUsersError("");
|
||||||
|
setTenantUsersSyncResult(null);
|
||||||
try {
|
try {
|
||||||
const users = await getTenantUsers(t.id);
|
const users = await getTenantUsers(t.id);
|
||||||
setTenantUsers(users || []);
|
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() {
|
async function handleAddDomain() {
|
||||||
if (!domainDialogTenant || !newDomain) return;
|
if (!domainDialogTenant || !newDomain) return;
|
||||||
setAddDomainLoading(true);
|
setAddDomainLoading(true);
|
||||||
@@ -2889,7 +2911,7 @@ export default function AdminPage() {
|
|||||||
<div className="py-4 text-center space-y-2">
|
<div className="py-4 text-center space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">Keine lokalen Benutzer diesem Mandanten zugewiesen.</p>
|
<p className="text-sm text-muted-foreground">Keine lokalen Benutzer diesem Mandanten zugewiesen.</p>
|
||||||
{tenantUsersDialogLdap && (
|
{tenantUsersDialogLdap && (
|
||||||
<p className="text-xs text-muted-foreground">LDAP ist aktiv — Benutzer erscheinen hier nach ihrem ersten Login.</p>
|
<p className="text-xs text-muted-foreground">LDAP ist aktiv — Benutzer erscheinen hier nach ihrem ersten Login oder nach der Synchronisation.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -2899,6 +2921,7 @@ export default function AdminPage() {
|
|||||||
<TableHead>Benutzername</TableHead>
|
<TableHead>Benutzername</TableHead>
|
||||||
<TableHead>E-Mail</TableHead>
|
<TableHead>E-Mail</TableHead>
|
||||||
<TableHead>Rolle</TableHead>
|
<TableHead>Rolle</TableHead>
|
||||||
|
<TableHead>Quelle</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -2908,6 +2931,7 @@ export default function AdminPage() {
|
|||||||
<TableCell className="font-medium">{u.username}</TableCell>
|
<TableCell className="font-medium">{u.username}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{u.email}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{u.email}</TableCell>
|
||||||
<TableCell><Badge variant="outline">{u.role}</Badge></TableCell>
|
<TableCell><Badge variant="outline">{u.role}</Badge></TableCell>
|
||||||
|
<TableCell><Badge variant="secondary">{u.source || "local"}</Badge></TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={u.active ? "default" : "secondary"}>
|
<Badge variant={u.active ? "default" : "secondary"}>
|
||||||
{u.active ? "Aktiv" : "Inaktiv"}
|
{u.active ? "Aktiv" : "Inaktiv"}
|
||||||
@@ -2918,6 +2942,31 @@ export default function AdminPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
{tenantUsersDialogLdap && (
|
||||||
|
<div className="border-t pt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSyncLDAPUsers}
|
||||||
|
disabled={tenantUsersSyncing}
|
||||||
|
>
|
||||||
|
{tenantUsersSyncing ? "Synchronisiere..." : "LDAP-Benutzer synchronisieren"}
|
||||||
|
</Button>
|
||||||
|
{tenantUsersSyncResult && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{tenantUsersSyncResult.synced} Benutzer synchronisiert
|
||||||
|
{tenantUsersSyncResult.errors.length > 0 && (
|
||||||
|
<span className="text-destructive ml-1">({tenantUsersSyncResult.errors.length} Fehler)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tenantUsersSyncResult?.errors?.length > 0 && (
|
||||||
|
<p className="text-xs text-destructive font-mono">{tenantUsersSyncResult.errors.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
source?: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tenant_id?: number;
|
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<LDAPSyncResult> {
|
||||||
|
return request<LDAPSyncResult>(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Profil-Einstellungen ──────────────────────────────────────────────────
|
// ── Profil-Einstellungen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
|
|||||||
Reference in New Issue
Block a user