Files
Pascal Bleser 5b72318493 chore(graph): add metrics for HTTP API and LDAP
Introducing gowrap as a build-time tool to generate interface delegate
structs from templates:

 * added as a 'make go-generate' target in services/graph,
 * added as a build-time dependency in .bingo/

Introduce an LDAP client abstraction interface to be able to wrap the
go-ldap client API with metrics transparently (and possibly hooks and
such in the future), in order to use delegation patterns to measure the
time LDAP (client) operations take to finish, as well as to track their
results (success, failure, not-found).

Has two implementations that are generated using gowrap:

* a go-ldap adapter implementation that directly delegates to a go-ldap
  connection
* a time measuring and metrics collecting implementation that delegates
  to another LdapClient

The metrics collecting one is disabled by default, can be enabled with
GRAPH_LDAP_METRICS_DISABLE=false

It collects durations of outbound LDAP client operations into a histogram, as
well as the number of concurrent outbound LDAP operations in a gauge (via an
atomic int and a gauge function, as that performs best).

Add an HTTP middleware that measures how long Graph HTTP API requests
take, storing taken time into a histogram along with labels for

 * method,
 * path pattern (from the chi routes),
 * Graph API version prefix,
 * Graph API resource name,
 * and the resulting status code.

It also tracks the number of concurrent inbound Graph API HTTP requests
using a gauge (also using an atomic int and a gauge function).

Disabled by default, can be enabled with
GRAPH_HTTP_METRICS_DISABLE=false

Add Backend and EducationBackend delegate implementations that measure
execution time on the level of the higher API call operations there
(CreateUser, DeleteUser, ..., CreateSchool, ...), generated using
gowrap.

Disabled by default, can be enabled with
GRAPH_IDENTITY_BACKEND_METRICS_DISABLE=false

Also added a small k6 script to produce some read-only load on the Graph
API, for a casual test of the metrics, as well as k6 in mise.toml.

Make an internal changes to how singular LDAP entry searches work in the LDAP
identity backends:

 * check whether searches for a singular entry returns more than one
   result, in which case a new error TooManyResults is returned, instead
   of leaving that undetected, blindly taking the first result, and
   potentially risking data inconsistencies

Improve the loggers in identity backends by adding attributes for their
request targets (Reva gateway address or LDAP URI, respectively).

Also add a "backend" attribute for all Graph API logs (set to "ldap" or
"cs3"), to help debug potential issues, and remove them from all the logger
debug calls at the beginning of each LDAP-related function as those should
really be part of the logger and set beforehand.

The LDAP identity backend logger also has two new attributes to help
debugging with logs:

 * write (bool): whether write operations are enabled
 * refint (bool): whether refint is enabled or not

Also adds a dedicated counter metric for user password change operations.

Minor campfire improvements:

 * add a constructor func for the CS3 backend

 * add a constructor func for the LDAP backend

 * in the LDAP identity backend, in searchLDAPEntryByFilter (used by all
   search/get public functions), errors that occur when performing LDAP
   SEARCH operations were blindly mapped to a ItemNotFound error,
   instead of being analyzed as it could be caused by a technical error

 * in the requireadmin middleware, add debug logging to explain why a
   request is denied

 * when an LDAP password change fails because the user entry was not
   found in LDAP, we now have a log message that tracks that
2026-09-01 10:57:47 +02:00

1551 lines
50 KiB
Go

package identity
import (
"context"
"errors"
"fmt"
"math"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/CiscoM31/godata"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"github.com/libregraph/idm/pkg/ldapdn"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/graph/pkg/config"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/graph/pkg/metrics"
"github.com/opencloud-eu/opencloud/services/graph/pkg/odata"
)
const (
givenNameAttribute = "givenname"
surNameAttribute = "sn"
identitiesAttribute = "openCloudExternalIdentity"
lastSignAttribute = "openCloudLastSignInTimestamp"
ldapDateFormat = "20060102150405Z0700"
)
// DisableUserMechanismType is used instead of directly using the string values from the configuration.
type DisableUserMechanismType int64
// The different DisableMechanism* constants are used for managing the enabling/disabling of users.
const (
DisableMechanismNone DisableUserMechanismType = iota
DisableMechanismAttribute
DisableMechanismGroup
)
var mechanismMap = map[string]DisableUserMechanismType{
"": DisableMechanismNone,
"none": DisableMechanismNone,
"attribute": DisableMechanismAttribute,
"group": DisableMechanismGroup,
}
type LDAP struct {
useServerUUID bool
writeEnabled bool
refintEnabled bool
usePwModifyExOp bool
userBaseDN string
userFilter string
userObjectClass string
userIDisOctetString bool
userScope int
userAttributeMap userAttributeMap
disableUserMechanism DisableUserMechanismType
localUserDisableGroupDN string
groupBaseDN string
groupCreateBaseDN string
groupFilter string
groupObjectClass string
groupIDisOctetString bool
groupScope int
groupAttributeMap groupAttributeMap
educationConfig educationConfig
logger *log.Logger
conn LdapClient
}
var _ Backend = &LDAP{}
var _ EducationBackend = &LDAP{}
type userAttributeMap struct {
displayName string
id string
mail string
userName string
givenName string
surname string
accountEnabled string
userType string
identities string
lastSignIn string
}
type ldapAttributeValues map[string][]string
type ldapResultToErrMap map[uint16]errorcode.Error
const ldapGenericErr = math.MaxUint16
// ParseDisableMechanismType checks that the configuration option for how to disable users is correct.
func ParseDisableMechanismType(disableMechanism string) (DisableUserMechanismType, error) {
disableMechanism = strings.ToLower(disableMechanism)
t, ok := mechanismMap[disableMechanism]
if !ok {
return -1, errors.New("invalid configuration option for disable user mechanism")
}
return t, nil
}
const (
MetricResultSuccess = "success"
MetricResultFailure = "failure"
MetricResultNotFound = "not-found"
MetricResultReadOnly = "read-only"
)
const (
MetricLabelOperation = "operation"
MetricLabelType = "type"
MetricLabelUri = "uri"
MetricLabelWrite = "write"
)
func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger, namespace string, subsystem string, registry prometheus.Registerer) (*LDAP, error) {
if config.UserDisplayNameAttribute == "" || config.UserIDAttribute == "" ||
config.UserEmailAttribute == "" || config.UserNameAttribute == "" {
return nil, errors.New("invalid user attribute mappings")
}
logger = &log.Logger{Logger: logger.With().
// Str("backend", "ldap"). // already added upstream
Bool("write", config.WriteEnabled).
Bool("refint", config.RefintEnabled).
Logger(),
}
uam := userAttributeMap{
displayName: config.UserDisplayNameAttribute,
id: config.UserIDAttribute,
mail: config.UserEmailAttribute,
userName: config.UserNameAttribute,
accountEnabled: config.UserEnabledAttribute,
givenName: givenNameAttribute,
surname: surNameAttribute,
userType: config.UserTypeAttribute,
identities: identitiesAttribute,
lastSignIn: lastSignAttribute,
}
if config.GroupNameAttribute == "" || config.GroupIDAttribute == "" {
return nil, errors.New("invalid group attribute mappings")
}
gam := groupAttributeMap{
name: config.GroupNameAttribute,
id: config.GroupIDAttribute,
member: config.GroupMemberAttribute,
}
var userScope, groupScope int
var err error
if userScope, err = stringToScope(config.UserSearchScope); err != nil {
return nil, fmt.Errorf("error configuring user scope: %w", err)
}
if groupScope, err = stringToScope(config.GroupSearchScope); err != nil {
return nil, fmt.Errorf("error configuring group scope: %w", err)
}
var educationConfig educationConfig
if educationConfig, err = newEducationConfig(config); err != nil {
return nil, fmt.Errorf("error setting up education resource config: %w", err)
}
disableMechanismType, err := ParseDisableMechanismType(config.DisableUserMechanism)
if err != nil {
return nil, fmt.Errorf("error configuring disable user mechanism: %w", err)
}
var client LdapClient
client = NewGoLdapLdapClient(lc)
if !config.Metrics.Disabled && registry != nil {
// metrics are enabled for the LDAP identity backend
// use a 'write' label to indicate whether this instance is read-only
// (write=="0") or allowed to make changes (wrote=="1")
write := "0"
if config.WriteEnabled {
write = "1"
}
// a metric that tracks the duration of the LDAP operations we perform as an LDAP client,
// will be passed to a Prometheus wrapper around LdapClient below
ldapEgressDuration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "ldap_client_operation_duration_seconds",
Help: "Duration of LDAP operations performed by the Graph service in seconds.",
Buckets: prometheus.DefBuckets,
ConstLabels: prometheus.Labels{
MetricLabelUri: config.URI,
MetricLabelWrite: write,
},
}, []string{MetricLabelOperation, metrics.LabelResult})
if err := registry.Register(ldapEgressDuration); err != nil {
logger.Warn().Err(err).Msg("failed to register LDAP egress duration metric")
}
// a metric that tracks the number of ongoing concurrent LDAP operations, will also be
// passed to a Prometheus wrapper around LdapClient below;
// note that we use an atomic int as a gauge to count operations up and down using the
// wrapper, and then a gauge func that retrieves the current value of that atomic int
// whenever scraped by Prometheus, as that approach performs better than calling inc/dec
// on a Gauge object directly:
var inFlight atomic.Int64
ldapEgressInFlight := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "ldap_client_operations",
Help: "Number of LDAP client operations in-flight in the Graph service.",
Unit: "operation",
ConstLabels: prometheus.Labels{
MetricLabelUri: config.URI,
MetricLabelWrite: write,
},
}, func() float64 {
// when scraped, we simply read the current value of the atomic int:
return float64(inFlight.Load())
})
if err := registry.Register(ldapEgressInFlight); err != nil {
logger.Warn().Err(err).Msg("failed to register LDAP egress in-flight metric")
}
// we sill use an LdapClient that wraps the "proper" LdapClient with recording the
// metrics referenced above:
client = NewPrometheusLdapClient(client, ldapEgressDuration, &inFlight)
}
return &LDAP{
useServerUUID: config.UseServerUUID,
usePwModifyExOp: config.UsePasswordModExOp,
userBaseDN: config.UserBaseDN,
userFilter: config.UserFilter,
userObjectClass: config.UserObjectClass,
userIDisOctetString: config.UserIDIsOctetString,
userScope: userScope,
userAttributeMap: uam,
groupBaseDN: config.GroupBaseDN,
groupCreateBaseDN: config.GroupCreateBaseDN,
groupFilter: config.GroupFilter,
groupObjectClass: config.GroupObjectClass,
groupIDisOctetString: config.GroupIDIsOctetString,
groupScope: groupScope,
groupAttributeMap: gam,
educationConfig: educationConfig,
disableUserMechanism: disableMechanismType,
localUserDisableGroupDN: config.LdapDisabledUsersGroupDN,
logger: logger,
conn: client,
writeEnabled: config.WriteEnabled,
refintEnabled: config.RefintEnabled,
}, nil
}
// CreateUser implements the Backend Interface. It converts the libregraph.User into an
// LDAP User Entry (using the inetOrgPerson LDAP Objectclass) add adds that to the
// configured LDAP server
func (i *LDAP) CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("CreateUser")
if !i.writeEnabled {
return nil, ErrReadOnly
}
if user.AccountEnabled != nil && i.disableUserMechanism == DisableMechanismNone {
return nil, errors.New("accountEnabled option not compatible with backend disable user mechanism")
}
ar, err := i.userToAddRequest(user)
if err != nil {
return nil, err
}
if err := i.conn.Add(ar); err != nil {
msg := "failed to add user"
logger.Error().Err(err).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultEntryAlreadyExists: errorcode.New(errorcode.NameAlreadyExists, "a user with that name already exists"),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
if i.usePwModifyExOp && user.PasswordProfile != nil && user.PasswordProfile.Password != nil {
if err := i.updateUserPassword(ctx, ar.DN, user.PasswordProfile.GetPassword()); err != nil {
return nil, err
}
}
// Read back user from LDAP to get the generated UUID
e, err := i.getUserByDN(ar.DN, "")
if err != nil {
return nil, err
}
return i.createUserModelFromLDAP(e)
}
// DeleteUser implements the Backend Interface. It permanently deletes a User identified
// by name or id from the LDAP server
func (i *LDAP) DeleteUser(ctx context.Context, nameOrID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("DeleteUser")
if !i.writeEnabled {
return ErrReadOnly
}
e, err := i.getLDAPUserByNameOrID(nameOrID)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
msg := "error deleting user"
logger.Error().Err(err).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, "user not found"),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return i.mapLDAPError(err, errMap)
}
if !i.refintEnabled {
// Find all the groups that this user was a member of and remove it from there
groupEntries, err := i.getLDAPGroupsByFilter(fmt.Sprintf("(%s=%s)", i.groupAttributeMap.member, e.DN), true, false)
if err != nil {
return err
}
for _, group := range groupEntries {
logger.Debug().Str("group", group.DN).Str("user", e.DN).Msg("Cleaning up group membership")
if err := i.removeEntryByDNAndAttributeFromEntry(group, e.DN, i.groupAttributeMap.member); err != nil {
// Errors when deleting the memberships are only logged as warnings but not returned
// to the user as we already successfully deleted the users itself
logger.Warn().Str("group", group.DN).Str("user", e.DN).Err(err).Msg("failed to remove member")
}
}
}
return nil
}
// UpdateUser implements the Backend Interface for the LDAP Backend
func (i *LDAP) UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("UpdateUser")
if !i.writeEnabled {
// still allow to enable/disable user when using DisableMechanismGroup
if i.disableUserMechanism == DisableMechanismGroup && isUserEnabledUpdate(user) {
logger.Error().Msg("Allowing accountEnabled Update on read-only backend")
} else {
return nil, ErrReadOnly
}
}
e, err := i.getLDAPUserByNameOrID(nameOrID)
if err != nil {
return nil, err
}
var updateNeeded bool
// Don't allow updates of the ID
if user.GetId() != "" {
id, err := i.ldapUUIDtoString(e, i.userAttributeMap.id, i.userIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", e.DN).Str(i.userAttributeMap.id, e.GetEqualFoldAttributeValue(i.userAttributeMap.id)).Msg("Invalid User. Cannot convert UUID")
return nil, errorcode.New(errorcode.GeneralException, "error converting uuid")
}
if id != user.GetId() {
return nil, errorcode.New(errorcode.NotAllowed, "changing the UserId is not allowed")
}
}
if user.GetOnPremisesSamAccountName() != "" {
if eu := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName); eu != user.GetOnPremisesSamAccountName() {
e, err = i.changeUserName(ctx, e.DN, eu, user.GetOnPremisesSamAccountName())
if err != nil {
return nil, err
}
}
}
mr := ldap.ModifyRequest{DN: e.DN}
properties := map[string]string{
i.userAttributeMap.displayName: user.GetDisplayName(),
i.userAttributeMap.mail: user.GetMail(),
i.userAttributeMap.surname: user.GetSurname(),
i.userAttributeMap.givenName: user.GetGivenName(),
i.userAttributeMap.userType: user.GetUserType(),
}
for attribute, value := range properties {
if value != "" {
if e.GetEqualFoldAttributeValue(attribute) != value {
mr.Replace(attribute, []string{value})
updateNeeded = true
}
}
}
if user.PasswordProfile != nil && user.PasswordProfile.GetPassword() != "" {
if i.usePwModifyExOp {
if err := i.updateUserPassword(ctx, e.DN, user.PasswordProfile.GetPassword()); err != nil {
msg := "error updating user password"
logger.Error().Err(err).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, "user not found"),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
} else {
// password are hashed server side there is no need to check if the new password
// is actually different from the old one.
mr.Replace("userPassword", []string{user.PasswordProfile.GetPassword()})
updateNeeded = true
}
}
if identities, ok := user.GetIdentitiesOk(); ok {
attrValues := make([]string, 0, len(identities))
for _, identity := range identities {
identityStr, err := i.identityToLDAPAttrValue(identity)
if err != nil {
return nil, err
}
attrValues = append(attrValues, identityStr)
}
mr.Replace(i.userAttributeMap.identities, attrValues)
updateNeeded = true
}
// Behavior of enabling/disabling of users depends on the "disableUserMechanism" config option:
//
// "attribute": For the upstream user management service which modifies accountEnabled on the user entry
// "group": Makes it possible for local admins to disable users by adding them to a special group
if user.AccountEnabled != nil {
un, err := i.updateAccountEnabledState(logger, user.GetAccountEnabled(), e, &mr)
if err != nil {
return nil, err
}
if un {
updateNeeded = true
}
}
if updateNeeded {
if err := i.conn.Modify(&mr); err != nil {
msg := "error updating user"
logger.Error().Err(err).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, "user not found"),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
}
// Read back user from LDAP to get the generated UUID
e, err = i.getUserByDN(e.DN, "")
if err != nil {
return nil, err
}
returnUser, err := i.createUserModelFromLDAP(e)
if err != nil {
return nil, err
}
if returnUser != nil {
// To avoid a ldap lookup for group membership, set the enabled flag to same as input value
// since this would have been updated with group membership from the input anyway.
if user.AccountEnabled != nil && i.disableUserMechanism == DisableMechanismGroup {
returnUser.AccountEnabled = user.AccountEnabled
}
}
return returnUser, nil
}
func (i *LDAP) getUserByDN(dn, searchTerm string) (*ldap.Entry, error) {
baseFilter := fmt.Sprintf("(objectClass=%s)", i.userObjectClass)
userFilter := ""
if i.userFilter != "" {
userFilter = fmt.Sprintf("(%s)", i.userFilter)
}
searchFilter := ""
if searchTerm != "" {
searchTerm = ldap.EscapeFilter(searchTerm)
searchFilter = fmt.Sprintf(
"(|(%s=*%s*)(%s=*%s*)(%s=*%s*))",
i.userAttributeMap.userName, searchTerm,
i.userAttributeMap.mail, searchTerm,
i.userAttributeMap.displayName, searchTerm,
)
}
filter := baseFilter
if userFilter != "" || searchFilter != "" {
filter = fmt.Sprintf("(&%s%s%s)", baseFilter, userFilter, searchFilter)
}
return i.getEntryByDN(dn, i.getUserAttrTypesForSearch(), filter)
}
func (i *LDAP) getEntryByDN(dn string, attrs []string, filter string) (*ldap.Entry, error) {
if filter == "" {
filter = "(objectclass=*)"
}
searchRequest := ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
filter,
attrs,
nil,
)
i.logger.Debug().
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("getEntryByDN")
res, err := i.conn.Search(searchRequest)
if err != nil {
i.logger.Error().Err(err).Str("dn", dn).Msg("Search ldap by DN failed")
msg := "user lookup failed"
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, msg),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultSizeLimitExceeded: errorcode.New(errorcode.TooManyResults, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
switch len(res.Entries) {
case 0:
return nil, ErrNotFound
case 1:
return res.Entries[0], nil
default:
return nil, ErrTooManyResults
}
}
// Retrieves a single entry from LDAP.
//
// It never returns nil for the *ldap.Entry:
// - if no object is found, it returns a ErrNotFound error
// - if more than one object is found, it returns a ErrTooManyResults error
// - if exactly one object is found, it returns that entry and no error
func (i *LDAP) searchLDAPEntryByFilter(basedn string, attrs []string, filter string) (*ldap.Entry, error) {
if filter == "" {
filter = "(objectclass=*)"
}
searchRequest := ldap.NewSearchRequest(
basedn,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 1, 0, false,
filter,
attrs,
nil,
)
i.logger.Debug().
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("getEntryByFilter")
res, err := i.conn.Search(searchRequest)
if err != nil {
i.logger.Error().Err(err).Str("dn", basedn).Str("filter", filter).Msg("Search user by filter failed")
msg := "user search failed"
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, msg),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultSizeLimitExceeded: errorcode.New(errorcode.TooManyResults, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
switch len(res.Entries) {
case 0:
return nil, ErrNotFound
case 1:
return res.Entries[0], nil
default:
return nil, ErrTooManyResults
}
}
func filterEscapeAttribute(attribute string, binary bool, id string) (string, error) {
var escaped string
if binary {
pid, err := uuid.Parse(id)
if err != nil {
err := fmt.Errorf("error parsing id '%s' as UUID: %w", id, err)
return "", err
}
escaped = filterEscapeBinaryUUID(attribute, pid)
} else {
escaped = ldap.EscapeFilter(id)
}
return escaped, nil
}
// swapObjectGUIDBytes converts between AD's mixed-endian objectGUID format and standard UUID byte order
func swapObjectGUIDBytes(value []byte) []byte {
if len(value) != 16 {
return value
}
return []byte{
value[3], value[2], value[1], value[0], // First component (4 bytes) - reverse
value[5], value[4], // Second component (2 bytes) - reverse
value[7], value[6], // Third component (2 bytes) - reverse
value[8], value[9], value[10], value[11], value[12], value[13], value[14], value[15], // Last 8 bytes - keep as-is
}
}
func filterEscapeBinaryUUID(attribute string, value uuid.UUID) string {
bytes := value[:]
// AD stores objectGUID with mixed endianness 🤪 - swap first 3 components
if strings.EqualFold(attribute, "objectguid") {
bytes = swapObjectGUIDBytes(bytes)
}
var filtered strings.Builder
filtered.Grow(len(bytes) * 3) // Pre-allocate: each byte becomes "\xx"
for _, b := range bytes {
fmt.Fprintf(&filtered, "\\%02x", b)
}
return filtered.String()
}
func (i *LDAP) getLDAPUserByID(id string) (*ldap.Entry, error) {
idString, err := filterEscapeAttribute(i.userAttributeMap.id, i.userIDisOctetString, id)
if err != nil {
return nil, fmt.Errorf("invalid User id: %w", err)
}
filter := fmt.Sprintf("(%s=%s)", i.userAttributeMap.id, idString)
return i.getLDAPUserByFilter(filter)
}
func (i *LDAP) getLDAPUserByNameOrID(nameOrID string) (*ldap.Entry, error) {
idString, err := filterEscapeAttribute(i.userAttributeMap.id, i.userIDisOctetString, nameOrID)
// err != nil just means that this is not an uuid, so we can skip the uuid filter part
// and just filter by name
var filter string
if err == nil {
filter = fmt.Sprintf("(|(%s=%s)(%s=%s))", i.userAttributeMap.userName, ldap.EscapeFilter(nameOrID), i.userAttributeMap.id, idString)
} else {
filter = fmt.Sprintf("(%s=%s)", i.userAttributeMap.userName, ldap.EscapeFilter(nameOrID))
}
return i.getLDAPUserByFilter(filter)
}
func (i *LDAP) getLDAPUserByFilter(filter string) (*ldap.Entry, error) {
filter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.userFilter, i.userObjectClass, filter)
return i.searchLDAPEntryByFilter(i.userBaseDN, i.getUserAttrTypesForSearch(), filter)
}
// GetUser implements the Backend Interface.
func (i *LDAP) GetUser(ctx context.Context, nameOrID string, oreq *godata.GoDataRequest) (*libregraph.User, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("GetUser")
e, err := i.getLDAPUserByNameOrID(nameOrID)
if err != nil {
return nil, err
}
u, err := i.createUserModelFromLDAP(e)
if err != nil {
return nil, err
}
if i.disableUserMechanism != DisableMechanismNone {
userEnabled, err := i.UserEnabled(e)
if err == nil {
u.AccountEnabled = &userEnabled
}
}
exp, err := odata.GetExpandValues(oreq.Query)
if err != nil {
return nil, err
}
if slices.Contains(exp, "memberOf") {
userGroups, err := i.getGroupsForUser(e.DN)
if err != nil {
return nil, err
}
if memberOf, err := i.groupsFromLDAPEntries(userGroups); err != nil {
// TODO: should we really just silently skip LDAP data model errors here, or rather return this as an error?
} else {
u.MemberOf = memberOf
}
}
return u, nil
}
// GetUsers implements the Backend Interface.
func (i *LDAP) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error) {
return i.FilterUsers(ctx, oreq, nil)
}
// FilterUsers implements the Backend Interface.
func (i *LDAP) FilterUsers(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode) ([]*libregraph.User, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("GetUsers")
queryFilter, err := i.oDataFilterToLDAPFilter(filter)
if err != nil {
return nil, err
}
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
exp, err := odata.GetExpandValues(oreq.Query)
if err != nil {
return nil, err
}
var userFilter string
if search != "" {
search = ldap.EscapeFilter(search)
userFilter = fmt.Sprintf(
"(|(%s=*%s*)(%s=*%s*)(%s=*%s*))",
i.userAttributeMap.userName, search,
i.userAttributeMap.mail, search,
i.userAttributeMap.displayName, search,
)
}
userFilter = fmt.Sprintf("(&%s(objectClass=%s)%s%s)", i.userFilter, i.userObjectClass, queryFilter, userFilter)
searchRequest := ldap.NewSearchRequest(
i.userBaseDN, i.userScope, ldap.NeverDerefAliases, 0, 0, false,
userFilter,
i.getUserAttrTypesForSearch(),
nil,
)
logger.Debug().
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetUsers")
res, err := i.conn.Search(searchRequest)
if err != nil {
msg := "error listing users"
logger.Error().Err(err).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.AccessDenied, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
return i.usersFromLDAPEntries(res.Entries, exp)
}
func (i *LDAP) usersFromLDAPEntries(entries []*ldap.Entry, exp []string) ([]*libregraph.User, error) {
usersEnabledState, err := i.usersEnabledState(entries)
if err != nil {
return nil, err
}
users := make([]*libregraph.User, 0, len(entries))
for _, e := range entries {
u, err := i.createUserModelFromLDAP(e)
if u == nil || err != nil {
// Skip invalid LDAP users
continue
}
if i.disableUserMechanism != DisableMechanismNone {
userEnabled := usersEnabledState[e.DN]
u.AccountEnabled = &userEnabled
}
if slices.Contains(exp, "memberOf") {
userGroups, err := i.getGroupsForUser(e.DN)
if err != nil {
return nil, err
}
if memberOf, err := i.groupsFromLDAPEntries(userGroups); err != nil {
// TODO: should we really just silently skip LDAP data model errors here, or rather return this as an error?
} else {
u.MemberOf = memberOf
}
}
users = append(users, u)
}
return users, nil
}
// UpdateLastSignInDate implements the Backend Interface.
func (i *LDAP) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error {
if !i.writeEnabled {
i.logger.Debug().Msg("The LDAP Server is readonly. Skipping update of last sign in date")
return nil // TODO: do we really want to just silently do nothing here, rather than returning an error?
}
e, err := i.getLDAPUserByID(userID)
switch {
case errors.Is(err, ErrNotFound):
i.logger.Warn().Err(err).Str("userID", userID).Msg("Failed to update last sign in date for user")
return nil // TODO questionable whether this should just fail silently because the user was not found
case err != nil:
return err
}
mr := ldap.ModifyRequest{DN: e.DN}
mr.Replace(lastSignAttribute, []string{timestamp.UTC().Format(ldapDateFormat)})
if err := i.conn.Modify(&mr); err != nil {
msg := "error updating last sign in date for user"
i.logger.Error().Err(err).Str("userid", userID).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, msg),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return i.mapLDAPError(err, errMap)
}
return nil
}
func (i *LDAP) changeUserName(ctx context.Context, dn, originalUserName, newUserName string) (*ldap.Entry, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.userAttributeMap.userName,
Value: newUserName,
}
newDNString := attributeTypeAndValue.String()
logger.Debug().Str("originalDN", dn).Str("newDN", newDNString).Msg("Modifying DN")
mrdn := ldap.NewModifyDNRequest(dn, newDNString, true, "")
if err := i.conn.ModifyDN(mrdn); err != nil {
msg := "error renaming user"
logger.Error().Err(err).Msg(msg)
errMap := ldapResultToErrMap{
ldap.LDAPResultEntryAlreadyExists: errorcode.New(errorcode.NameAlreadyExists, "a user with that name already exists"),
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, msg),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return nil, i.mapLDAPError(err, errMap)
}
parsed, err := ldap.ParseDN(dn)
if err != nil {
return nil, err
}
newFullDN, err := replaceDN(parsed, newDNString)
if err != nil {
return nil, err
}
u, err := i.getUserByDN(newFullDN, "")
if err != nil {
return nil, err
}
if !i.refintEnabled {
groups, err := i.getGroupsForUser(dn)
if err != nil {
return nil, err
}
for _, g := range groups {
logger.Debug().Str("originalDN", dn).Str("newDN", u.DN).Str("group", g.DN).Msg("Changing member in group")
err = i.renameMemberInGroup(ctx, g, dn, u.DN)
// This could leave the groups in an inconsistent state, might be a good idea to
// add a defer that changes everything back on error. Ideally, this entire function
// should be atomic, but LDAP doesn't support that.
if err != nil {
return nil, err
}
}
}
return u, nil
}
func (i *LDAP) renameMemberInGroup(ctx context.Context, group *ldap.Entry, oldMember, newMember string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("oldMember", oldMember).Str("newMember", newMember).Msg("replacing group member")
mr := ldap.NewModifyRequest(group.DN, nil)
mr.Delete(i.groupAttributeMap.member, []string{oldMember})
mr.Add(i.groupAttributeMap.member, []string{newMember})
if err := i.conn.Modify(mr); err != nil {
logger.Warn().Err(err).
Str("oldMember", oldMember).
Str("newMember", newMember).
Str("groupDN", group.DN).
Msg("Error renaming group members.")
var lerr *ldap.Error
if errors.As(err, &lerr) {
// NoSuchObject means that the group no longer exists. Some other client might have deleted it. There is
// not much we can do
// NoSuchAttribute means that the old value is no longer present. We can't do much here either
if lerr.ResultCode == ldap.LDAPResultNoSuchObject || lerr.ResultCode == ldap.LDAPResultNoSuchAttribute {
return nil
}
}
return err
}
return nil
}
func (i *LDAP) updateUserPassword(ctx context.Context, dn, password string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("updateUserPassword")
pwMod := ldap.PasswordModifyRequest{
UserIdentity: dn,
NewPassword: password,
}
// Note: We can ignore the result message here, as it were only relevant if we requested
// the server to generate a new Password
_, err := i.conn.PasswordModify(&pwMod)
if err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error setting password for user")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
}
return err
}
func (i *LDAP) ldapUUIDtoString(e *ldap.Entry, attribute string, binary bool) (string, error) {
if binary {
value := e.GetEqualFoldRawAttributeValue(attribute)
if len(value) != 16 {
return "", fmt.Errorf("invalid UUID in '%s' attribute (got %d bytes)", attribute, len(value))
}
// AD stores objectGUID with mixed endianness 🤪 - swap first 3 components
if strings.EqualFold(attribute, "objectguid") {
value = swapObjectGUIDBytes(value)
}
id, err := uuid.FromBytes(value)
if err != nil {
return "", fmt.Errorf("error parsing UUID from '%s' attribute bytes: %w", attribute, err)
}
return id.String(), nil
}
return e.GetEqualFoldAttributeValue(attribute), nil
}
func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) (*libregraph.User, error) {
if e == nil {
return nil, nil
}
opsan := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName)
id, err := i.ldapUUIDtoString(e, i.userAttributeMap.id, i.userIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", e.DN).Str(i.userAttributeMap.id, e.GetEqualFoldAttributeValue(i.userAttributeMap.id)).Msg("Invalid User. Cannot convert UUID")
}
surname := e.GetEqualFoldAttributeValue(i.userAttributeMap.surname)
if id != "" && opsan != "" {
user := &libregraph.User{
DisplayName: e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName),
Mail: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.mail)),
OnPremisesSamAccountName: opsan,
Id: &id,
GivenName: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.givenName)),
Surname: &surname,
AccountEnabled: booleanOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.accountEnabled)),
}
userType := e.GetEqualFoldAttributeValue(i.userAttributeMap.userType)
if userType == "" {
userType = UserTypeMember
}
user.SetUserType(userType)
var identities []libregraph.ObjectIdentity
for _, identityStr := range e.GetEqualFoldAttributeValues(i.userAttributeMap.identities) {
parts := strings.SplitN(identityStr, "$", 3)
identity := libregraph.NewObjectIdentity()
identity.SetIssuer(strings.TrimSpace(parts[1]))
identity.SetIssuerAssignedId(strings.TrimSpace(parts[2]))
identities = append(identities, *identity)
}
if len(identities) > 0 {
user.SetIdentities(identities)
}
lastSignIn, err := i.getLastSignTime(e)
switch {
case err == nil:
signinActivity := libregraph.SignInActivity{
LastSuccessfulSignInDateTime: lastSignIn,
}
user.SetSignInActivity(signinActivity)
case !errors.Is(err, errNotSet):
i.logger.Warn().Err(err).Str("dn", e.DN).Msg("Error getting last signin timestamp")
}
return user, nil
}
err = errorcode.New(errorcode.GeneralException, "Invalid User. Missing username or id attribute")
i.logger.Warn().Str("dn", e.DN).Str("id", id).Str("username", opsan).Msg("Invalid User. Missing username or id attribute")
return nil, err
}
func (i *LDAP) userToLDAPAttrValues(user libregraph.User) (map[string][]string, error) {
attrs := map[string][]string{
i.userAttributeMap.displayName: {user.GetDisplayName()},
i.userAttributeMap.userName: {user.GetOnPremisesSamAccountName()},
i.userAttributeMap.mail: {user.GetMail()},
"objectClass": {"inetOrgPerson", "organizationalPerson", "person", "top", "openCloudUser"},
"cn": {user.GetOnPremisesSamAccountName()},
i.userAttributeMap.userType: {user.GetUserType()},
}
if identities, ok := user.GetIdentitiesOk(); ok {
for _, identity := range identities {
identityStr, err := i.identityToLDAPAttrValue(identity)
if err != nil {
return nil, err
}
attrs[i.userAttributeMap.identities] = append(
attrs[i.userAttributeMap.identities],
identityStr,
)
}
}
if !i.useServerUUID {
attrs[i.userAttributeMap.id] = []string{uuid.New().String()}
}
if user.AccountEnabled != nil {
attrs[i.userAttributeMap.accountEnabled] = []string{
strings.ToUpper(strconv.FormatBool(*user.AccountEnabled)),
}
}
// inetOrgPerson requires "sn" to be set. Set it to the Username if
// Surname is not set in the Request
var sn string
if user.Surname != nil && *user.Surname != "" {
sn = *user.Surname
} else {
sn = user.OnPremisesSamAccountName
}
attrs[i.userAttributeMap.surname] = []string{sn}
// When we get a givenName, we set the attribute.
if givenName := user.GetGivenName(); givenName != "" {
attrs[i.userAttributeMap.givenName] = []string{givenName}
}
if !i.usePwModifyExOp && user.PasswordProfile != nil && user.PasswordProfile.Password != nil {
// Depending on the LDAP server implementation this might cause the
// password to be stored in cleartext in the LDAP database. Using the
// "Password Modify LDAP Extended Operation" is recommended.
attrs["userPassword"] = []string{*user.PasswordProfile.Password}
}
return attrs, nil
}
func (i *LDAP) identityToLDAPAttrValue(identity libregraph.ObjectIdentity) (string, error) {
// TODO add support for the "signInType" of objectIdentity
if identity.GetIssuer() == "" || identity.GetIssuerAssignedId() == "" {
return "", fmt.Errorf("missing Attribute for objectIdentity")
}
identityStr := fmt.Sprintf(" $ %s $ %s", identity.GetIssuer(), identity.GetIssuerAssignedId())
return identityStr, nil
}
func (i *LDAP) getUserAttrTypesForSearch() []string {
return []string{
i.userAttributeMap.displayName,
i.userAttributeMap.id,
i.userAttributeMap.mail,
i.userAttributeMap.userName,
i.userAttributeMap.surname,
i.userAttributeMap.givenName,
i.userAttributeMap.accountEnabled,
i.userAttributeMap.userType,
i.userAttributeMap.identities,
i.userAttributeMap.lastSignIn,
}
}
func (i *LDAP) getUserAttrTypes() []string {
return append(i.getUserAttrTypesForSearch(), "cn", "userPassword", "objectClass")
}
func (i *LDAP) getUserLDAPDN(user libregraph.User) string {
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: "uid",
Value: user.OnPremisesSamAccountName,
}
return fmt.Sprintf("%s,%s", attributeTypeAndValue.String(), i.userBaseDN)
}
func (i *LDAP) userToAddRequest(user libregraph.User) (*ldap.AddRequest, error) {
ar := ldap.NewAddRequest(i.getUserLDAPDN(user), nil)
attrMap, err := i.userToLDAPAttrValues(user)
if err != nil {
return nil, err
}
for _, attrType := range i.getUserAttrTypes() {
if values, ok := attrMap[attrType]; ok {
ar.Attribute(attrType, values)
}
}
return ar, nil
}
func pointerOrNil(val string) *string {
if val == "" {
return nil
}
return &val
}
func booleanOrNil(val string) *bool {
boolValue, err := strconv.ParseBool(val)
if err != nil {
return nil
}
return &boolValue
}
func stringToScope(scope string) (int, error) {
var s int
switch scope {
case "sub":
s = ldap.ScopeWholeSubtree
case "one":
s = ldap.ScopeSingleLevel
case "base":
s = ldap.ScopeBaseObject
default:
return 0, fmt.Errorf("invalid Scope '%s'", scope)
}
return s, nil
}
// removeEntryByDNAndAttributeFromEntry creates a request to remove a single member entry by attribute and DN from a ldap entry
func (i *LDAP) removeEntryByDNAndAttributeFromEntry(entry *ldap.Entry, dn string, attribute string) error {
nOldDN, err := ldapdn.ParseNormalize(dn)
if err != nil {
return err
}
currentValues := entry.GetEqualFoldAttributeValues(attribute)
i.logger.Debug().Interface("members", currentValues).Msg("current values")
found := false
for _, currentValue := range currentValues {
if currentValue == "" {
continue
}
if normalizedCurrentValue, err := ldapdn.ParseNormalize(currentValue); err != nil {
// We couldn't parse the entry value as a DN. Let's keep it
// as it is but log a warning
i.logger.Warn().Str("member", currentValue).Err(err).Msg("Couldn't parse DN")
continue
} else {
if normalizedCurrentValue == nOldDN {
found = true
}
}
}
if !found {
i.logger.Error().Str("entry", entry.DN).Str("target", dn).
Msg("The target value is not present in the attribute list")
return ErrNotFound
}
mr := &ldap.ModifyRequest{DN: entry.DN}
if len(currentValues) == 1 {
mr.Add(attribute, []string{""})
}
mr.Delete(attribute, []string{dn})
err = i.conn.Modify(mr)
var lerr *ldap.Error
if err != nil && errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultObjectClassViolation {
// objectclass "groupOfName" requires at least one member to be present, some other go-routine
// must have removed the 2nd last member from the group after we read the group. We adapt the
// modification request to replace the last member with an empty member and re-try.
i.logger.Debug().Err(err).
Msg("Failed to remove last group member. Retrying once. Replacing last group member with an empty member value.")
mr.Add(attribute, []string{""})
err = i.conn.Modify(mr)
}
}
if err != nil {
i.logger.Error().Err(err).Str("entry", entry.DN).Str("attribute", attribute).Str("target value", dn).
Msg("Failed to remove dn attribute from entry")
msg := "failed to update object"
errMap := ldapResultToErrMap{
ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, "object does not exists"),
ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg),
ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg),
ldapGenericErr: errorcode.New(errorcode.GeneralException, msg),
}
return i.mapLDAPError(err, errMap)
}
return nil
}
// expandLDAPAttributeEntries reads an attribute from a ldap entry and expands to users
func (i *LDAP) expandLDAPAttributeEntries(ctx context.Context, e *ldap.Entry, attribute, searchTerm string) ([]*ldap.Entry, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Msg("ExpandLDAPAttributeEntries")
result := []*ldap.Entry{}
for _, entryDN := range e.GetEqualFoldAttributeValues(attribute) {
if entryDN == "" {
continue
}
logger.Debug().Str("entryDN", entryDN).Msg("lookup")
ue, err := i.getUserByDN(entryDN, searchTerm)
if err != nil {
// Ignore errors when reading a specific entry fails, just log them and continue
logger.Warn().Err(err).Str("entry", entryDN).Msg("error reading attribute member entry")
continue
}
result = append(result, ue)
}
return result, nil
}
func replaceDN(fullDN *ldap.DN, newDN string) (string, error) {
if len(fullDN.RDNs) == 0 {
return "", fmt.Errorf("can't operate on an empty dn")
}
if len(fullDN.RDNs) == 1 {
return newDN, nil
}
for _, part := range fullDN.RDNs[1:] {
newDN += "," + part.String()
}
return newDN, nil
}
// CreateLDAPGroupByDN is a helper method specifically intended for creating a "system" group
// for managing locally disabled users on service startup
func (i *LDAP) CreateLDAPGroupByDN(dn string) error {
ar := ldap.NewAddRequest(dn, nil)
attrs := map[string][]string{
"objectClass": {"groupOfNames", "top"},
"member": {""},
}
for attrType, values := range attrs {
ar.Attribute(attrType, values)
}
return i.conn.Add(ar)
}
func (i *LDAP) addUserToDisableGroup(logger log.Logger, userDN string) (err error) {
groupFilter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
group, err := i.getEntryByDN(i.localUserDisableGroupDN, []string{i.groupAttributeMap.member}, groupFilter)
if err != nil {
return err
}
mr := ldap.ModifyRequest{DN: group.DN}
mr.Add(i.groupAttributeMap.member, []string{userDN})
err = i.conn.Modify(&mr)
var lerr *ldap.Error
if errors.As(err, &lerr) {
// If the user is already in the group, just log a message and return
if lerr.ResultCode == ldap.LDAPResultAttributeOrValueExists {
logger.Info().Msg("User already in group for disabled users")
return nil
}
}
return err
}
func (i *LDAP) removeUserFromDisableGroup(logger log.Logger, userDN string) (err error) {
groupFilter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
group, err := i.getEntryByDN(i.localUserDisableGroupDN, []string{i.groupAttributeMap.member}, groupFilter)
if err != nil {
return err
}
mr := ldap.ModifyRequest{DN: group.DN}
mr.Delete(i.groupAttributeMap.member, []string{userDN})
err = i.conn.Modify(&mr)
var lerr *ldap.Error
if errors.As(err, &lerr) {
// If the user is not in the group, just log a message and return
if lerr.ResultCode == ldap.LDAPResultNoSuchAttribute {
logger.Info().Msg("User was not in group for disabled users")
return nil
}
}
return err
}
func (i *LDAP) userEnabledByAttribute(user *ldap.Entry) bool {
enabledAttribute := booleanOrNil(user.GetEqualFoldAttributeValue(i.userAttributeMap.accountEnabled))
if enabledAttribute == nil {
return true
}
return *enabledAttribute
}
func (i *LDAP) usersEnabledStateFromGroup(users []string) (usersEnabledState map[string]bool, err error) {
groupFilter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
group, err := i.getEntryByDN(i.localUserDisableGroupDN, []string{i.groupAttributeMap.member}, groupFilter)
if err != nil {
return nil, err
}
usersEnabledState = make(map[string]bool, len(users))
for _, user := range users {
usersEnabledState[user] = true
}
for _, memberDN := range group.GetEqualFoldAttributeValues(i.groupAttributeMap.member) {
usersEnabledState[memberDN] = false
}
return usersEnabledState, err
}
// UserEnabled returns if a user is enabled. This can depend on a flag in the user entry or group membership
func (i *LDAP) UserEnabled(user *ldap.Entry) (bool, error) {
usersEnabledState, err := i.usersEnabledState([]*ldap.Entry{user})
if err != nil {
return false, err
}
return usersEnabledState[user.DN], nil
}
func (i *LDAP) usersEnabledState(users []*ldap.Entry) (usersEnabledState map[string]bool, err error) {
usersEnabledState = make(map[string]bool, len(users))
keys := make([]string, len(users))
for index, user := range users {
usersEnabledState[user.DN] = true
keys[index] = user.DN
}
switch i.disableUserMechanism {
case DisableMechanismAttribute:
for _, user := range users {
usersEnabledState[user.DN] = i.userEnabledByAttribute(user)
}
case DisableMechanismGroup:
userDisabledGroupState, err := i.usersEnabledStateFromGroup(keys)
if err != nil {
return nil, err
}
for _, user := range keys {
usersEnabledState[user] = userDisabledGroupState[user]
}
}
return usersEnabledState, nil
}
// Behavior of enabling/disabling of users depends on the "disableUserMechanism" config option:
//
// "attribute": For the upstream user management service which modifies accountEnabled on the user entry
// "group": Makes it possible for local admins to disable users by adding them to a special group
func (i *LDAP) updateAccountEnabledState(logger log.Logger, accountEnabled bool, e *ldap.Entry, mr *ldap.ModifyRequest) (updateNeeded bool, err error) {
switch i.disableUserMechanism {
case DisableMechanismNone:
err = errors.New("accountEnabled option not compatible with backend disable user mechanism")
case DisableMechanismAttribute:
boolString := strings.ToUpper(strconv.FormatBool(accountEnabled))
ldapValue := e.GetEqualFoldAttributeValue(i.userAttributeMap.accountEnabled)
if ldapValue != "" {
mr.Replace(i.userAttributeMap.accountEnabled, []string{boolString})
} else {
mr.Add(i.userAttributeMap.accountEnabled, []string{boolString})
}
updateNeeded = true
case DisableMechanismGroup:
if accountEnabled {
err = i.removeUserFromDisableGroup(logger, e.DN)
} else {
err = i.addUserToDisableGroup(logger, e.DN)
}
updateNeeded = false
}
return updateNeeded, err
}
func (i *LDAP) mapLDAPError(err error, errmap ldapResultToErrMap) errorcode.Error {
var lerr *ldap.Error
if errors.As(err, &lerr) {
if res, ok := errmap[lerr.ResultCode]; ok {
return res
}
}
if res, ok := errmap[ldapGenericErr]; ok {
return res
}
return errorcode.New(errorcode.GeneralException, err.Error())
}
func (i *LDAP) getLastSignTime(e *ldap.Entry) (*time.Time, error) {
dateString := e.GetEqualFoldAttributeValue(i.userAttributeMap.lastSignIn)
if dateString == "" {
return nil, errNotSet
}
t, err := time.Parse(ldapDateFormat, dateString)
if err != nil {
err = fmt.Errorf("error parsing LDAP date: '%s': %w", dateString, err)
return nil, err
}
return &t, nil
}
func (i *LDAP) oDataFilterToLDAPFilter(filter *godata.ParseNode) (string, error) {
if filter == nil {
return "", nil
}
if filter.Token.Type != godata.ExpressionTokenLogical {
return "", ErrUnsupportedFilter
}
if filter.Token.Value != "le" {
return "", ErrUnsupportedFilter
}
if !isLastSuccessFullSignInDateTimeFilter(filter.Children[0]) {
return "", ErrUnsupportedFilter
}
if filter.Children[1].Token.Type != godata.ExpressionTokenDateTime {
return "", ErrUnsupportedFilter
}
parsed, err := time.Parse(time.RFC3339, filter.Children[1].Token.Value)
if err != nil {
return "", godata.BadRequestError("invalid date format")
}
ldapDateTime := parsed.UTC().Format(ldapDateFormat)
return fmt.Sprintf("(%s<=%s)", i.userAttributeMap.lastSignIn, ldap.EscapeFilter(ldapDateTime)), nil
}
func isLastSuccessFullSignInDateTimeFilter(node *godata.ParseNode) bool {
if node.Token.Type != godata.ExpressionTokenNav {
return false
}
if len(node.Children) != 2 {
return false
}
if node.Children[0].Token.Value != "signInActivity" {
return false
}
if node.Children[1].Token.Value != "lastSuccessfulSignInDateTime" {
return false
}
return true
}
func isUserEnabledUpdate(user libregraph.UserUpdate) bool {
switch {
case user.Id != nil, user.DisplayName != nil,
user.Drive != nil, user.Mail != nil, user.OnPremisesSamAccountName != nil,
user.PasswordProfile != nil, user.Surname != nil, user.GivenName != nil,
user.UserType != nil:
return false
case len(user.AppRoleAssignments) != 0,
len(user.MemberOf) != 0,
len(user.Identities) != 0,
len(user.Drives) != 0:
return false
}
return user.AccountEnabled != nil
}