mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-03 21:36:44 +00:00
fix(ldap): do not disable every user when a sync returns no identifiers (#744)
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>
This commit is contained in:
@@ -193,6 +193,42 @@ func (m Manager) disableMissingLdapUsers(
|
||||
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
|
||||
@@ -215,20 +251,14 @@ func (m Manager) disableMissingLdapUsers(
|
||||
continue // skip sync for this user
|
||||
}
|
||||
|
||||
existsInLDAP := false
|
||||
for _, rawUser := range rawUsers {
|
||||
userId := ldapUserIdentifier(rawUser, fields.UserIdentifier)
|
||||
if user.Identifier == userId {
|
||||
existsInLDAP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existsInLDAP {
|
||||
if _, existsInLDAP := ldapUserIds[user.Identifier]; existsInLDAP {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user