mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-03 21:36:44 +00:00
disable_missing tested absence against the raw sync result without checking that the search returned anything usable, so a search that succeeds and yields nothing looked like "every user has been removed". Connection and search errors were already safe, since synchronizeLdapUsers returns before the disable phase. The gap is the successful-but-empty case: a base_dn or sync_filter that stops matching, an unpopulated replica, a field_map user_identifier naming an attribute the server does not return, or a bind account that lost read access to the user subtree. LDAP gives nothing to tell those apart from a directory that is genuinely empty; they all answer success with zero entries. Acting on it is not a database flag. TopicUserDisabled removes each user's peers from the WireGuard device, the successful search means no error is logged, and every message on the path was Debug while log_level defaults to info, so the whole event was silent. It also repeats every sync interval. Refuse to disable anyone when no usable identifier came back, logging the provider, entry count and identifier field. The guard counts identifiers, not entries, so it covers the field_map case too. The per-user disable line moves from Debug to Warn so a mass disable is audible even where the guard does not fire. The cost is that a directory intentionally emptied of users now disables nobody. That is documented, along with the workaround: leave one account matching sync_filter and everyone else is disabled as before. Signed-off-by: clark-ja <37738506+clark-ja@users.noreply.github.com>
346 lines
11 KiB
Go
346 lines
11 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"github.com/h44z/wg-portal/internal"
|
|
"github.com/h44z/wg-portal/internal/app"
|
|
"github.com/h44z/wg-portal/internal/config"
|
|
"github.com/h44z/wg-portal/internal/domain"
|
|
)
|
|
|
|
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
|
|
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
|
|
|
|
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
|
|
go func(cfg config.LdapProvider) {
|
|
syncInterval := cfg.SyncInterval
|
|
if syncInterval == 0 {
|
|
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
|
|
return
|
|
}
|
|
|
|
// perform initial sync
|
|
err := m.synchronizeLdapUsers(ctx, &cfg)
|
|
if err != nil {
|
|
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
|
} else {
|
|
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
|
|
}
|
|
|
|
// start periodic sync
|
|
running := true
|
|
for running {
|
|
select {
|
|
case <-ctx.Done():
|
|
running = false
|
|
continue
|
|
case <-time.After(syncInterval):
|
|
// select blocks until one of the cases evaluate to true
|
|
}
|
|
|
|
err := m.synchronizeLdapUsers(ctx, &cfg)
|
|
if err != nil {
|
|
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
|
}
|
|
}
|
|
}(ldapCfg)
|
|
}
|
|
}
|
|
|
|
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
|
|
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
|
|
|
|
dn, err := ldap.ParseDN(provider.AdminGroupDN)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse admin group DN: %w", err)
|
|
}
|
|
provider.ParsedAdminGroupDN = dn
|
|
|
|
conn, err := internal.LdapConnect(provider)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to setup LDAP connection: %w", err)
|
|
}
|
|
defer internal.LdapDisconnect(conn)
|
|
|
|
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
|
|
|
|
// Update existing LDAP users
|
|
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Disable missing LDAP users
|
|
if provider.DisableMissing {
|
|
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update interface allowed users based on LDAP filters
|
|
err = m.updateInterfaceLdapFilters(ctx, conn, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m Manager) updateLdapUsers(
|
|
ctx context.Context,
|
|
provider *config.LdapProvider,
|
|
rawUsers []internal.RawLdapUser,
|
|
fields *config.LdapFields,
|
|
adminGroupDN *ldap.DN,
|
|
) error {
|
|
for _, rawUser := range rawUsers {
|
|
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
if errors.Is(err, domain.ErrInvalidData) {
|
|
slog.Warn("skipping LDAP user with invalid data after sanitization",
|
|
"raw-dn", rawUser["dn"], "error", err)
|
|
continue
|
|
}
|
|
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
|
|
}
|
|
|
|
if provider.SyncLogUserInfo {
|
|
slog.Debug("ldap user data",
|
|
"raw-user", rawUser, "user", user.Identifier,
|
|
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
|
|
}
|
|
|
|
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
|
|
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
|
|
|
|
if existingUser == nil {
|
|
// create new user
|
|
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
|
|
|
|
_, err := m.create(tctx, user)
|
|
if err != nil {
|
|
cancel()
|
|
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
} else {
|
|
// update existing user
|
|
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
|
|
user.Disabled = nil
|
|
user.DisabledReason = ""
|
|
} else {
|
|
user.Disabled = existingUser.Disabled
|
|
user.DisabledReason = existingUser.DisabledReason
|
|
}
|
|
|
|
if existingUser.PersistLocalChanges {
|
|
cancel()
|
|
continue // skip synchronization for this user
|
|
}
|
|
|
|
if userChangedInLdap(existingUser, user) {
|
|
syncedUser, err := m.users.GetUser(ctx, user.Identifier)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
cancel()
|
|
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
syncedUser.UpdatedAt = time.Now()
|
|
syncedUser.UpdatedBy = domain.CtxSystemLdapSyncer
|
|
syncedUser.MergeAuthSources(user.Authentications...)
|
|
syncedUser.Email = user.Email
|
|
syncedUser.Firstname = user.Firstname
|
|
syncedUser.Lastname = user.Lastname
|
|
syncedUser.Phone = user.Phone
|
|
syncedUser.Department = user.Department
|
|
syncedUser.IsAdmin = user.IsAdmin
|
|
syncedUser.Disabled = user.Disabled
|
|
syncedUser.DisabledReason = user.DisabledReason
|
|
|
|
_, err = m.update(tctx, existingUser, syncedUser, false)
|
|
if err != nil {
|
|
cancel()
|
|
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m Manager) disableMissingLdapUsers(
|
|
ctx context.Context,
|
|
providerName string,
|
|
rawUsers []internal.RawLdapUser,
|
|
fields *config.LdapFields,
|
|
) error {
|
|
// Collect the identifiers the directory actually returned. A hard LDAP
|
|
// failure is already handled by the caller, but a search that *succeeds*
|
|
// and yields nothing usable is indistinguishable here from "every user was
|
|
// removed from the directory". In practice that means a wrong base DN, a
|
|
// sync_filter that no longer matches, a replica that is up but not yet
|
|
// populated, a field map pointing at an attribute the server does not
|
|
// return, or a bind account that has lost read access to the user subtree.
|
|
// LDAP offers nothing to tell those apart: a search that matches nobody and
|
|
// one the client is not allowed to answer both come back as success with
|
|
// zero entries, and a directory server may reply that way for a base DN
|
|
// that does not exist rather than disclose it to an unprivileged client.
|
|
//
|
|
// Acting on it disables every LDAP-sourced user at once, and through
|
|
// TopicUserDisabled that removes each of their peers from the WireGuard
|
|
// device rather than only flagging them. The search succeeded, so no error
|
|
// is logged, and every message on this path is Debug: at the default log
|
|
// level the whole thing is silent. It also repeats every sync interval.
|
|
// Refuse instead. The cost is that a directory intentionally emptied of all
|
|
// users disables nobody; leaving a single account in scope restores the
|
|
// normal behaviour for everyone else.
|
|
ldapUserIds := make(map[domain.UserIdentifier]struct{}, len(rawUsers))
|
|
for _, rawUser := range rawUsers {
|
|
if userId := ldapUserIdentifier(rawUser, fields.UserIdentifier); userId != "" {
|
|
ldapUserIds[userId] = struct{}{}
|
|
}
|
|
}
|
|
if len(ldapUserIds) == 0 {
|
|
slog.Error("refusing to disable missing LDAP users: directory returned no usable user identifiers",
|
|
"provider", providerName,
|
|
"raw-entries", len(rawUsers),
|
|
"identifier-field", fields.UserIdentifier,
|
|
"hint", "check base_dn, sync_filter and the bind account's read permissions; "+
|
|
"if the directory is intentionally empty, keep one account matching sync_filter")
|
|
return nil
|
|
}
|
|
|
|
allUsers, err := m.users.GetAllUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, user := range allUsers {
|
|
userHasAuthSource := false
|
|
for _, auth := range user.Authentications {
|
|
if auth.Source == domain.UserSourceLdap && auth.ProviderName == providerName {
|
|
userHasAuthSource = true
|
|
break
|
|
}
|
|
}
|
|
if !userHasAuthSource {
|
|
continue // ignore non ldap users
|
|
}
|
|
if user.IsDisabled() {
|
|
continue // ignore deactivated
|
|
}
|
|
if user.PersistLocalChanges {
|
|
continue // skip sync for this user
|
|
}
|
|
|
|
if _, existsInLDAP := ldapUserIds[user.Identifier]; existsInLDAP {
|
|
continue
|
|
}
|
|
|
|
// Warn, not Debug: this removes the user's peers from the device, and at
|
|
// the default log level a Debug line would make a mass disable silent.
|
|
slog.Warn("user is missing in ldap provider, disabling",
|
|
"user", user.Identifier, "provider", providerName)
|
|
|
|
now := time.Now()
|
|
user.Disabled = &now
|
|
user.DisabledReason = domain.DisabledReasonLdapMissing
|
|
|
|
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
|
u.Disabled = user.Disabled
|
|
u.DisabledReason = user.DisabledReason
|
|
return u, nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
|
|
}
|
|
|
|
m.bus.Publish(app.TopicUserDisabled, user)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m Manager) updateInterfaceLdapFilters(
|
|
ctx context.Context,
|
|
conn *ldap.Conn,
|
|
provider *config.LdapProvider,
|
|
) error {
|
|
if len(provider.InterfaceFilter) == 0 {
|
|
return nil // nothing to do if no interfaces are configured for this provider
|
|
}
|
|
|
|
for ifaceName, groupFilter := range provider.InterfaceFilter {
|
|
ifaceId := domain.InterfaceIdentifier(ifaceName)
|
|
|
|
// Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
|
|
combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
|
|
|
|
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
|
|
if err != nil {
|
|
slog.Error("failed to find users for interface filter",
|
|
"interface", ifaceId,
|
|
"provider", provider.ProviderName,
|
|
"error", err)
|
|
continue
|
|
}
|
|
|
|
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
|
|
for _, rawUser := range rawUsers {
|
|
userId := ldapUserIdentifier(rawUser, provider.FieldMap.UserIdentifier)
|
|
if userId != "" {
|
|
matchedUserIds = append(matchedUserIds, userId)
|
|
}
|
|
}
|
|
|
|
// Save the interface
|
|
err = m.interfaces.SaveInterface(ctx, ifaceId, func(i *domain.Interface) (*domain.Interface, error) {
|
|
if i.LdapAllowedUsers == nil {
|
|
i.LdapAllowedUsers = make(map[string][]domain.UserIdentifier)
|
|
}
|
|
i.LdapAllowedUsers[provider.ProviderName] = matchedUserIds
|
|
return i, nil
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to save interface ldap allowed users",
|
|
"interface", ifaceId,
|
|
"provider", provider.ProviderName,
|
|
"error", err)
|
|
} else {
|
|
slog.Debug("updated interface ldap allowed users",
|
|
"interface", ifaceId,
|
|
"provider", provider.ProviderName,
|
|
"matched_count", len(matchedUserIds))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ldapUserIdentifier(rawUser map[string]any, field string) domain.UserIdentifier {
|
|
identifier := internal.MapDefaultString(rawUser, field, "")
|
|
identifier = domain.SanitizeIdentifier(identifier, 256)
|
|
if identifier == "" {
|
|
return ""
|
|
}
|
|
return domain.UserIdentifier(identifier)
|
|
}
|