Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,168 @@
package identity
import (
"context"
"net/url"
"time"
"github.com/CiscoM31/godata"
cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
// Errors used by the interfaces
var (
// ErrReadOnly signals that the backend is set to read only.
ErrReadOnly = errorcode.New(errorcode.NotAllowed, "server is configured read-only")
// ErrNotFound signals that the requested resource was not found.
ErrNotFound = errorcode.New(errorcode.ItemNotFound, "not found")
// ErrUnsupportedFilter signals that the requested filter is not supported by the backend.
ErrUnsupportedFilter = godata.NotImplementedError("unsupported filter")
)
const (
UserTypeMember = "Member"
UserTypeGuest = "Guest"
UserTypeFederated = "Federated"
)
// Backend defines the Interface for an IdentityBackend implementation
type Backend interface {
// CreateUser creates a given user in the identity backend.
CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error)
// DeleteUser deletes a given user, identified by username or id, from the backend
DeleteUser(ctx context.Context, nameOrID string) error
// UpdateUser applies changes to given user, identified by username or id
UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error)
GetUser(ctx context.Context, nameOrID string, oreq *godata.GoDataRequest) (*libregraph.User, error)
GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error)
// FilterUsers returns a list of users that match the filter
FilterUsers(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode) ([]*libregraph.User, error)
UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error
// CreateGroup creates the supplied group in the identity backend.
CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error)
// DeleteGroup deletes a given group, identified by id
DeleteGroup(ctx context.Context, id string) error
// UpdateGroupName updates the group name
UpdateGroupName(ctx context.Context, groupID string, groupName string) error
GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error)
GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error)
// GetGroupMembers list all members of a group
GetGroupMembers(ctx context.Context, id string, oreq *godata.GoDataRequest) ([]*libregraph.User, error)
// AddMembersToGroup adds new members (reference by a slice of IDs) to supplied group in the identity backend.
AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error
// RemoveMemberFromGroup removes a single member (by ID) from a group
RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error
}
// EducationBackend defines the Interface for an EducationBackend implementation
type EducationBackend interface {
// CreateEducationSchool creates the supplied school in the identity backend.
CreateEducationSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error)
// DeleteEducationSchool deletes a given school, identified by id
DeleteEducationSchool(ctx context.Context, id string) error
// GetEducationSchool reads a given school by id
GetEducationSchool(ctx context.Context, nameOrID string) (*libregraph.EducationSchool, error)
// GetEducationSchools lists all schools
GetEducationSchools(ctx context.Context) ([]*libregraph.EducationSchool, error)
// FilterEducationSchoolsByAttribute list all schools where an attribute matches a value, e.g. all schools with a given externalId
FilterEducationSchoolsByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationSchool, error)
// UpdateEducationSchool updates attributes of a school
UpdateEducationSchool(ctx context.Context, numberOrID string, school libregraph.EducationSchool) (*libregraph.EducationSchool, error)
// GetEducationSchoolUsers lists all members of a school
GetEducationSchoolUsers(ctx context.Context, id string) ([]*libregraph.EducationUser, error)
// AddUsersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
AddUsersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error
// RemoveUserFromEducationSchool removes a single member (by ID) from a school
RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error
// GetEducationSchoolClasses lists all classes in a school
GetEducationSchoolClasses(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationClass, error)
// AddClassesToEducationSchool adds new classes (referenced by a slice of IDs) to supplied school in the identity backend.
AddClassesToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error
// RemoveClassFromEducationSchool removes a class from a school.
RemoveClassFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error
// GetEducationClasses lists all classes
GetEducationClasses(ctx context.Context) ([]*libregraph.EducationClass, error)
// GetEducationClass reads a given class by id
GetEducationClass(ctx context.Context, namedOrID string) (*libregraph.EducationClass, error)
// CreateEducationClass creates the supplied education class in the identity backend.
CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error)
// DeleteEducationClass deletes the supplied education class in the identity backend.
DeleteEducationClass(ctx context.Context, nameOrID string) error
// GetEducationClassMembers returns the EducationUser members for an EducationClass
GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error)
// UpdateEducationClass updates properties of the supplied class in the identity backend.
UpdateEducationClass(ctx context.Context, id string, class libregraph.EducationClass) (*libregraph.EducationClass, error)
// CreateEducationUser creates a given education user in the identity backend.
CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error)
// DeleteEducationUser deletes a given education user, identified by username or id, from the backend
DeleteEducationUser(ctx context.Context, nameOrID string) error
// UpdateEducationUser applies changes to given education user, identified by username or id
UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error)
// GetEducationUser reads an education user by id or name
GetEducationUser(ctx context.Context, nameOrID string) (*libregraph.EducationUser, error)
// GetEducationUsers lists all education users
GetEducationUsers(ctx context.Context) ([]*libregraph.EducationUser, error)
// FilterEducationUsersByAttribute list all education users where and attribute matches a value, e.g. all users with a given externalid
FilterEducationUsersByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationUser, error)
// GetEducationClassTeachers returns the EducationUser teachers for an EducationClass
GetEducationClassTeachers(ctx context.Context, classID string) ([]*libregraph.EducationUser, error)
// AddTeacherToEducationClass adds a teacher (by ID) to class in the identity backend.
AddTeacherToEducationClass(ctx context.Context, classID string, teacherID string) error
// RemoveTeacherFromEducationClass removes teacher (by ID) from a class
RemoveTeacherFromEducationClass(ctx context.Context, classID string, teacherID string) error
}
// CreateUserModelFromCS3 converts a cs3 User object into a libregraph.User
func CreateUserModelFromCS3(u *cs3user.User) *libregraph.User {
if u.GetId() == nil {
u.Id = &cs3user.UserId{}
}
userType := CS3UserTypeToGraph(u.GetId().GetType())
user := &libregraph.User{
Identities: []libregraph.ObjectIdentity{{
Issuer: &u.GetId().Idp,
IssuerAssignedId: &u.GetId().OpaqueId,
}},
UserType: &userType,
DisplayName: u.GetDisplayName(),
Mail: &u.Mail,
OnPremisesSamAccountName: u.GetUsername(),
Id: &u.GetId().OpaqueId,
}
if u.GetId().GetType() == cs3user.UserType_USER_TYPE_FEDERATED {
ocmUserId := u.GetId().GetOpaqueId() + "@" + u.GetId().GetIdp()
user.Id = &ocmUserId
}
return user
}
func CS3UserTypeToGraph(cs3type cs3user.UserType) string {
switch cs3type {
case cs3user.UserType_USER_TYPE_PRIMARY:
return UserTypeMember
case cs3user.UserType_USER_TYPE_FEDERATED:
return UserTypeFederated
case cs3user.UserType_USER_TYPE_GUEST:
return UserTypeGuest
}
return "unknown"
}
// CreateGroupModelFromCS3 converts a cs3 Group object into a libregraph.Group
func CreateGroupModelFromCS3(g *cs3group.Group) *libregraph.Group {
if g.GetId() == nil {
g.Id = &cs3group.GroupId{}
}
return &libregraph.Group{
Id: &g.Id.OpaqueId,
DisplayName: &g.GroupName,
}
}
+215
View File
@@ -0,0 +1,215 @@
package cache
import (
"context"
"errors"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3Group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/jellydator/ttlcache/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
revautils "github.com/opencloud-eu/reva/v2/pkg/utils"
)
// IdentityCache implements a simple ttl based cache for looking up users and groups by ID
type IdentityCache struct {
users *ttlcache.Cache[string, *cs3User.User]
groups *ttlcache.Cache[string, libregraph.Group]
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
type identityCacheOptions struct {
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
usersTTL time.Duration
groupsTTL time.Duration
}
// IdentityCacheOption defines a single option function.
type IdentityCacheOption func(o *identityCacheOptions)
// IdentityCacheWithGatewaySelector set the gatewaySelector for the Identity Cache
func IdentityCacheWithGatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) IdentityCacheOption {
return func(o *identityCacheOptions) {
o.gatewaySelector = gatewaySelector
}
}
// IdentityCacheWithUsersTTL sets the TTL for the users cache
func IdentityCacheWithUsersTTL(ttl time.Duration) IdentityCacheOption {
return func(o *identityCacheOptions) {
o.usersTTL = ttl
}
}
// IdentityCacheWithGroupsTTL sets the TTL for the groups cache
func IdentityCacheWithGroupsTTL(ttl time.Duration) IdentityCacheOption {
return func(o *identityCacheOptions) {
o.groupsTTL = ttl
}
}
func newOptions(opts ...IdentityCacheOption) identityCacheOptions {
opt := identityCacheOptions{}
for _, o := range opts {
o(&opt)
}
return opt
}
// NewIdentityCache instantiates a new IdentityCache and sets the supplied options
func NewIdentityCache(opts ...IdentityCacheOption) IdentityCache {
opt := newOptions(opts...)
var cache IdentityCache
cache.users = ttlcache.New(
ttlcache.WithTTL[string, *cs3user.User](opt.usersTTL),
ttlcache.WithDisableTouchOnHit[string, *cs3user.User](),
)
go cache.users.Start()
cache.groups = ttlcache.New(
ttlcache.WithTTL[string, libregraph.Group](opt.groupsTTL),
ttlcache.WithDisableTouchOnHit[string, libregraph.Group](),
)
go cache.groups.Start()
cache.gatewaySelector = opt.gatewaySelector
return cache
}
// GetUser looks up a user by id, if the user is not cached, yet it will do a lookup via the CS3 API
func (cache IdentityCache) GetUser(ctx context.Context, tenantId, userid string) (libregraph.User, error) {
// can we get the tenant from the context or do we have to pass it?
u, err := cache.GetCS3User(ctx, tenantId, userid)
if err != nil {
return libregraph.User{}, err
}
if tenantId != u.GetId().GetTenantId() {
return libregraph.User{}, identity.ErrNotFound
}
return *identity.CreateUserModelFromCS3(u), nil
}
func (cache IdentityCache) GetCS3User(ctx context.Context, tenantId, userid string) (*cs3User.User, error) {
var user *cs3User.User
if item := cache.users.Get(tenantId + "|" + userid); item == nil {
gatewayClient, err := cache.gatewaySelector.Next()
if err != nil {
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
cs3UserID := &cs3User.UserId{
OpaqueId: userid,
TenantId: tenantId,
}
user, err = revautils.GetUserNoGroups(ctx, cs3UserID, gatewayClient)
if err != nil {
if revautils.IsErrNotFound(err) {
return nil, identity.ErrNotFound
}
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
cache.users.Set(tenantId+"|"+userid, user, ttlcache.DefaultTTL)
} else {
user = item.Value()
}
return user, nil
}
// GetAcceptedUser looks up a user by id, if the user is not cached, yet it will do a lookup via the CS3 API
func (cache IdentityCache) GetAcceptedUser(ctx context.Context, userid string) (libregraph.User, error) {
u, err := cache.GetAcceptedCS3User(ctx, userid)
if err != nil {
return libregraph.User{}, err
}
return *identity.CreateUserModelFromCS3(u), nil
}
func getIDAndMeshProvider(user string) (id, provider string, err error) {
last := strings.LastIndex(user, "@")
if last == -1 {
return "", "", errors.New("not in the form <id>@<provider>")
}
if len(user[:last]) == 0 {
return "", "", errors.New("empty id")
}
if len(user[last+1:]) == 0 {
return "", "", errors.New("empty provider")
}
return user[:last], user[last+1:], nil
}
func (cache IdentityCache) GetAcceptedCS3User(ctx context.Context, userid string) (*cs3User.User, error) {
var user *cs3user.User
if item := cache.users.Get(userid); item == nil {
gatewayClient, err := cache.gatewaySelector.Next()
if err != nil {
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
id, provider, err := getIDAndMeshProvider(userid)
if err != nil {
return nil, errorcode.New(errorcode.InvalidRequest, err.Error())
}
cs3UserID := &cs3User.UserId{
Idp: provider,
OpaqueId: id,
Type: cs3User.UserType_USER_TYPE_FEDERATED,
}
user, err = revautils.GetAcceptedUserWithContext(ctx, cs3UserID, gatewayClient)
if err != nil {
if revautils.IsErrNotFound(err) {
return nil, identity.ErrNotFound
}
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
cache.users.Set(userid, user, ttlcache.DefaultTTL)
} else {
user = item.Value()
}
return user, nil
}
// GetGroup looks up a group by id, if the group is not cached, yet it will do a lookup via the CS3 API
func (cache IdentityCache) GetGroup(ctx context.Context, groupID string) (libregraph.Group, error) {
var group libregraph.Group
if item := cache.groups.Get(groupID); item == nil {
gatewayClient, err := cache.gatewaySelector.Next()
if err != nil {
return group, errorcode.New(errorcode.GeneralException, err.Error())
}
cs3GroupID := &cs3Group.GroupId{
OpaqueId: groupID,
}
req := cs3Group.GetGroupRequest{
GroupId: cs3GroupID,
SkipFetchingMembers: true,
}
res, err := gatewayClient.GetGroup(ctx, &req)
if err != nil {
return group, errorcode.New(errorcode.GeneralException, err.Error())
}
switch res.Status.Code {
case rpc.Code_CODE_OK:
g := res.GetGroup()
group = *identity.CreateGroupModelFromCS3(g)
cache.groups.Set(groupID, group, ttlcache.DefaultTTL)
case rpc.Code_CODE_NOT_FOUND:
return group, identity.ErrNotFound
default:
return group, errorcode.New(errorcode.GeneralException, res.Status.Message)
}
} else {
group = item.Value()
}
return group, nil
}
@@ -0,0 +1,13 @@
package cache_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCache(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Cache Suite")
}
+106
View File
@@ -0,0 +1,106 @@
package cache
import (
"context"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// mockGatewaySelector is a mock implementation of pool.Selectable[gateway.GatewayAPIClient]
type mockGatewaySelector struct {
client gateway.GatewayAPIClient
}
func (m *mockGatewaySelector) Next(opts ...pool.Option) (gateway.GatewayAPIClient, error) {
return m.client, nil
}
var _ = Describe("Cache", func() {
var (
ctx context.Context
idc IdentityCache
mockGwSelector pool.Selectable[gateway.GatewayAPIClient]
)
BeforeEach(func() {
// Create a mock gateway selector (client can be nil for cached tests)
mockGwSelector = &mockGatewaySelector{
client: nil,
}
idc = NewIdentityCache(
IdentityCacheWithGatewaySelector(mockGwSelector),
)
ctx = context.Background()
})
Describe("GetUser", func() {
It("should return no error", func() {
alan := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "",
},
DisplayName: "Alan",
}
// Persist the user to the cache for 1 hour
idc.users.Set(alan.GetId().GetTenantId()+"|"+alan.GetId().GetOpaqueId(), alan, time.Hour)
// getting the cache item in cache.go line 103 does not work
ru, err := idc.GetUser(ctx, "", "alan")
Expect(err).To(BeNil())
Expect(ru).ToNot(BeNil())
Expect(ru.GetId()).To(Equal(alan.GetId().GetOpaqueId()))
Expect(ru.GetDisplayName()).To(Equal(alan.GetDisplayName()))
})
It("should return the correct user if two users with the same uid and different tennant ids exist", func() {
alan1 := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "1234",
},
DisplayName: "Alan1",
}
alan2 := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "5678",
},
DisplayName: "Alan2",
}
// Persist the user to the cache for 1 hour
idc.users.Set(alan1.GetId().GetTenantId()+"|"+alan1.GetId().GetOpaqueId(), alan1, time.Hour)
idc.users.Set(alan2.GetId().GetTenantId()+"|"+alan2.GetId().GetOpaqueId(), alan2, time.Hour)
ru, err := idc.GetUser(ctx, "5678", "alan")
Expect(err).To(BeNil())
Expect(ru.GetDisplayName()).To(Equal(alan2.GetDisplayName()))
ru, err = idc.GetUser(ctx, "1234", "alan")
Expect(err).To(BeNil())
Expect(ru.GetDisplayName()).To(Equal(alan1.GetDisplayName()))
})
It("should not return an error, if the tenant id does match", func() {
alan := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "1234",
},
DisplayName: "Alan",
}
// Persist the user to the cache for 1 hour
cu := idc.users.Set(alan.GetId().GetTenantId()+"|"+alan.GetId().GetOpaqueId(), alan, time.Hour)
// Test if element has been persisted in the cache
Expect(cu.Value().GetId().GetOpaqueId()).To(Equal(alan.GetId().GetOpaqueId()))
ru, err := idc.GetUser(ctx, "1234", "alan")
Expect(err).To(BeNil())
Expect(ru.GetDisplayName()).To(Equal(alan.GetDisplayName()))
})
})
})
+261
View File
@@ -0,0 +1,261 @@
package identity
import (
"context"
"net/url"
"time"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/odata"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
var (
errNotImplemented = errorcode.New(errorcode.NotSupported, "not implemented")
)
type CS3 struct {
Config *shared.Reva
Logger *log.Logger
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// CreateUser implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error) {
return nil, errNotImplemented
}
// DeleteUser implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) DeleteUser(ctx context.Context, nameOrID string) error {
return errNotImplemented
}
// UpdateUser implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error) {
return nil, errNotImplemented
}
// GetUser implements the Backend Interface.
func (i *CS3) GetUser(ctx context.Context, nameOrId string, _ *godata.GoDataRequest) (*libregraph.User, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetUser")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
// Try to get the user by username first
res, err := gatewayClient.GetUserByClaim(ctx, &cs3user.GetUserByClaimRequest{
Claim: "username", // FIXME add consts to reva
Value: nameOrId,
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
return CreateUserModelFromCS3(res.GetUser()), nil
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
// If the user was not found by username, try to get it by user ID
default:
logger.Debug().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage())
}
// If the user was not found by username, try to get it by user ID
res, err = gatewayClient.GetUserByClaim(ctx, &cs3user.GetUserByClaimRequest{
Claim: "userid", // FIXME add consts to reva
Value: nameOrId,
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.GetStatus().GetMessage())
}
logger.Debug().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage())
}
return CreateUserModelFromCS3(res.GetUser()), nil
}
// GetUsers implements the Backend Interface.
func (i *CS3) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetUsers")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
res, err := gatewayClient.FindUsers(ctx, &cs3user.FindUsersRequest{
// FIXME presence match is currently not implemented, an empty search currently leads to
// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
Filters: []*cs3user.Filter{
{
Type: cs3user.Filter_TYPE_QUERY,
Term: &cs3user.Filter_Query{
Query: search,
},
},
},
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find users grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.GetStatus().GetMessage())
}
logger.Debug().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find users grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage())
}
users := make([]*libregraph.User, 0, len(res.GetUsers()))
for _, user := range res.GetUsers() {
users = append(users, CreateUserModelFromCS3(user))
}
return users, nil
}
// FilterUsers implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) FilterUsers(_ context.Context, _ *godata.GoDataRequest, _ *godata.ParseNode) ([]*libregraph.User, error) {
return nil, errNotImplemented
}
// UpdateLastSignInDate implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error {
return errNotImplemented
}
// GetGroups implements the Backend Interface.
func (i *CS3) GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetGroups")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
res, err := gatewayClient.FindGroups(ctx, &cs3group.FindGroupsRequest{
// FIXME presence match is currently not implemented, an empty search currently leads to
// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
Filters: []*cs3group.Filter{
{
Type: cs3group.Filter_TYPE_QUERY,
Term: &cs3group.Filter_Query{
Query: search,
},
},
},
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find groups grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
}
logger.Debug().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find groups grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
}
groups := make([]*libregraph.Group, 0, len(res.GetGroups()))
for _, group := range res.GetGroups() {
groups = append(groups, CreateGroupModelFromCS3(group))
}
return groups, nil
}
// CreateGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
return nil, errNotImplemented
}
// GetGroup implements the Backend Interface.
func (i *CS3) GetGroup(ctx context.Context, groupID string, queryParam url.Values) (*libregraph.Group, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetGroup")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
res, err := gatewayClient.GetGroupByClaim(ctx, &cs3group.GetGroupByClaimRequest{
Claim: "group_id", // FIXME add consts to reva
Value: groupID,
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
}
logger.Debug().Str("backend", "cs3").Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
}
return CreateGroupModelFromCS3(res.GetGroup()), nil
}
// DeleteGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) DeleteGroup(ctx context.Context, id string) error {
return errNotImplemented
}
// UpdateGroupName implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) UpdateGroupName(ctx context.Context, groupID string, groupName string) error {
return errNotImplemented
}
// GetGroupMembers implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) GetGroupMembers(ctx context.Context, groupID string, _ *godata.GoDataRequest) ([]*libregraph.User, error) {
return nil, errNotImplemented
}
// AddMembersToGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error {
return errNotImplemented
}
// RemoveMemberFromGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
return errNotImplemented
}
@@ -0,0 +1,145 @@
package identity
import (
"context"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// ErrEducationBackend is a dummy EducationBackend, doing nothing
type ErrEducationBackend struct{}
// CreateEducationSchool creates the supplied school in the identity backend.
func (i *ErrEducationBackend) CreateEducationSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// DeleteEducationSchool deletes a given school, identified by id
func (i *ErrEducationBackend) DeleteEducationSchool(ctx context.Context, id string) error {
return errNotImplemented
}
// GetEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchool(ctx context.Context, nameOrID string) (*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// GetEducationSchools implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchools(ctx context.Context) ([]*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// FilterEducationSchoolsByAttribute implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) FilterEducationSchoolsByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// UpdateEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) UpdateEducationSchool(ctx context.Context, numberOrID string, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// GetEducationSchoolUsers implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchoolUsers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationSchoolClasses implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchoolClasses(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// AddClassesToEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) AddClassesToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error {
return errNotImplemented
}
// RemoveClassFromEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) RemoveClassFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error {
return errNotImplemented
}
// AddUsersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
func (i *ErrEducationBackend) AddUsersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error {
return errNotImplemented
}
// RemoveUserFromEducationSchool removes a single member (by ID) from a school
func (i *ErrEducationBackend) RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error {
return errNotImplemented
}
// GetEducationClasses implements the EducationBackend interface
func (i *ErrEducationBackend) GetEducationClasses(ctx context.Context) ([]*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// GetEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) GetEducationClass(ctx context.Context, namedOrID string) (*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// CreateEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// DeleteEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error {
return errNotImplemented
}
// GetEducationClassMembers implements the EducationBackend interface
func (i *ErrEducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// UpdateEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) UpdateEducationClass(ctx context.Context, id string, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// CreateEducationUser creates a given education user in the identity backend.
func (i *ErrEducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// DeleteEducationUser deletes a given education user, identified by username or id, from the backend
func (i *ErrEducationBackend) DeleteEducationUser(ctx context.Context, nameOrID string) error {
return errNotImplemented
}
// UpdateEducationUser applies changes to given education user, identified by username or id
func (i *ErrEducationBackend) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationUser implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationUser(ctx context.Context, nameOrID string) (*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationUsers implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationUsers(ctx context.Context) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// FilterEducationUsersByAttribute implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) FilterEducationUsersByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationClassTeachers implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationClassTeachers(ctx context.Context, classID string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// AddTeacherToEducationClass implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) AddTeacherToEducationClass(ctx context.Context, classID string, teacherID string) error {
return errNotImplemented
}
// RemoveTeacherFromEducationClass implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) RemoveTeacherFromEducationClass(ctx context.Context, classID string, teacherID string) error {
return errNotImplemented
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,464 @@
package identity
import (
"context"
"errors"
"fmt"
"github.com/go-ldap/ldap/v3"
"github.com/libregraph/idm/pkg/ldapdn"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type educationClassAttributeMap struct {
externalID string
classification string
teachers string
}
func newEducationClassAttributeMap() educationClassAttributeMap {
return educationClassAttributeMap{
externalID: "qsferaEducationExternalId",
classification: "qsferaEducationClassType",
teachers: "qsferaEducationTeacherMember",
}
}
// GetEducationClasses implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationClasses(ctx context.Context) ([]*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationClasses")
classFilter := fmt.Sprintf("(&%s(objectClass=%s))", i.groupFilter, i.educationConfig.classObjectClass)
classAttrs := i.getEducationClassAttrTypes(false)
searchRequest := ldap.NewSearchRequest(
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
classFilter,
classAttrs,
nil,
)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetEducationClasses")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
classes := make([]*libregraph.EducationClass, 0, len(res.Entries))
var c *libregraph.EducationClass
for _, e := range res.Entries {
if c = i.createEducationClassModelFromLDAP(e); c == nil {
continue
}
classes = append(classes, c)
}
return classes, nil
}
// CreateEducationClass implements the EducationBackend interface for the LDAP backend.
// An EducationClass is mapped to an LDAP entry of the "groupOfNames" structural ObjectClass.
// With a few additional Attributes added on top via the "qsferaEducationClass" auxiliary ObjectClass.
func (i *LDAP) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("create educationClass")
if !i.writeEnabled {
return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ar, err := i.educationClassToAddRequest(class)
if err != nil {
return nil, err
}
if err := i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error adding class")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
return nil, err
}
// Read back group from LDAP to get the generated UUID
e, err := i.getEducationClassByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createEducationClassModelFromLDAP(e), nil
}
// GetEducationClass implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationClass(ctx context.Context, id string) (*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationClass")
e, err := i.getEducationClassByID(id, false)
if err != nil {
return nil, err
}
var class *libregraph.EducationClass
if class = i.createEducationClassModelFromLDAP(e); class == nil {
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
}
return class, nil
}
// DeleteEducationClass implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) DeleteEducationClass(ctx context.Context, id string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteEducationClass")
if !i.writeEnabled {
return ErrReadOnly
}
e, err := i.getEducationClassByID(id, false)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
// TODO update any users that are member of this school
return nil
}
// UpdateEducationClass implements the EducationBackend interface for the LDAP backend.
// Only the displayName and externalID are supported to change at this point.
func (i *LDAP) UpdateEducationClass(ctx context.Context, id string, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationClass")
if !i.writeEnabled {
return nil, ErrReadOnly
}
g, err := i.getLDAPGroupByID(id, false)
if err != nil {
return nil, err
}
var updateNeeded bool
if class.GetId() != "" {
id, err := i.ldapUUIDtoString(g, i.groupAttributeMap.id, i.groupIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", g.DN).Str(i.userAttributeMap.id, g.GetEqualFoldAttributeValue(i.userAttributeMap.id)).Msg("Invalid class. Cannot convert UUID")
return nil, errorcode.New(errorcode.GeneralException, "error converting uuid")
}
if id != class.GetId() {
return nil, errorcode.New(errorcode.NotAllowed, "changing the GroupID is not allowed")
}
}
if class.GetDescription() != "" {
return nil, errorcode.New(errorcode.NotSupported, "changing the description is currently not supported")
}
if len(class.GetMembers()) != 0 {
return nil, errorcode.New(errorcode.NotSupported, "changing the members is currently not supported")
}
if class.GetClassification() != "" {
return nil, errorcode.New(errorcode.NotSupported, "changing the classification is currently not supported")
}
dn := g.DN
if eID := class.GetExternalId(); eID != "" {
if g.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.externalID) != eID {
dn, err = i.updateClassExternalID(ctx, dn, eID)
if err != nil {
return nil, err
}
}
}
mr := ldap.ModifyRequest{DN: dn}
if dName := class.GetDisplayName(); dName != "" {
if g.GetEqualFoldAttributeValue(i.groupAttributeMap.name) != dName {
mr.Replace(i.groupAttributeMap.name, []string{dName})
updateNeeded = true
}
}
if updateNeeded {
if err := i.conn.Modify(&mr); err != nil {
return nil, err
}
}
g, err = i.getEducationClassByDN(dn)
if err != nil {
return nil, err
}
return i.createEducationClassModelFromLDAP(g), nil
}
func (i *LDAP) updateClassExternalID(ctx context.Context, dn, externalID string) (string, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
newDN := fmt.Sprintf("qsferaEducationExternalId=%s", externalID)
mrdn := ldap.NewModifyDNRequest(dn, newDN, true, "")
i.logger.Debug().Str("Backend", "ldap").
Str("dn", mrdn.DN).
Str("newrdn", mrdn.NewRDN).
Msg("updating class external ID")
if err := i.conn.ModifyDN(mrdn); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error updating class external ID")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
return "", err
}
return fmt.Sprintf("%s,%s", newDN, i.groupBaseDN), nil
}
// GetEducationClassMembers implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationClassMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationClassMembers")
e, err := i.getEducationClassByID(id, true)
if err != nil {
return nil, err
}
memberEntries, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, "")
result := make([]*libregraph.EducationUser, 0, len(memberEntries))
if err != nil {
return nil, err
}
for _, member := range memberEntries {
if u := i.createEducationUserModelFromLDAP(member); u != nil {
result = append(result, u)
}
}
return result, nil
}
func (i *LDAP) educationClassToAddRequest(class libregraph.EducationClass) (*ldap.AddRequest, error) {
plainGroup := i.educationClassToGroup(class)
ldapAttrs, err := i.groupToLDAPAttrValues(*plainGroup)
if err != nil {
return nil, err
}
ldapAttrs, err = i.educationClassToLDAPAttrValues(class, ldapAttrs)
if err != nil {
return nil, err
}
ar := ldap.NewAddRequest(i.getEducationClassLDAPDN(class), nil)
for attrType, values := range ldapAttrs {
ar.Attribute(attrType, values)
}
return ar, nil
}
func (i *LDAP) educationClassToGroup(class libregraph.EducationClass) *libregraph.Group {
group := libregraph.NewGroup()
group.SetDisplayName(class.DisplayName)
return group
}
func (i *LDAP) educationClassToLDAPAttrValues(class libregraph.EducationClass, attrs ldapAttributeValues) (ldapAttributeValues, error) {
if externalID, ok := class.GetExternalIdOk(); ok {
attrs[i.educationConfig.classAttributeMap.externalID] = []string{*externalID}
}
if classification, ok := class.GetClassificationOk(); ok {
attrs[i.educationConfig.classAttributeMap.classification] = []string{*classification}
}
attrs["objectClass"] = append(attrs["objectClass"], i.educationConfig.classObjectClass)
return attrs, nil
}
func (i *LDAP) getEducationClassAttrTypes(requestMembers bool) []string {
attrs := []string{
i.groupAttributeMap.name,
i.groupAttributeMap.id,
i.educationConfig.classAttributeMap.classification,
i.educationConfig.classAttributeMap.externalID,
i.educationConfig.memberOfSchoolAttribute,
i.educationConfig.classAttributeMap.teachers,
}
if requestMembers {
attrs = append(attrs, i.groupAttributeMap.member)
}
return attrs
}
func (i *LDAP) getEducationClassByDN(dn string) (*ldap.Entry, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.classObjectClass)
if i.groupFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
}
return i.getEntryByDN(dn, i.getEducationClassAttrTypes(false), filter)
}
func (i *LDAP) createEducationClassModelFromLDAP(e *ldap.Entry) *libregraph.EducationClass {
group := i.createGroupModelFromLDAP(e)
return i.groupToEducationClass(*group, e)
}
func (i *LDAP) groupToEducationClass(group libregraph.Group, e *ldap.Entry) *libregraph.EducationClass {
class := libregraph.NewEducationClass(group.GetDisplayName(), "")
class.SetId(group.GetId())
if e != nil {
// Set the education User specific Attributes from the supplied LDAP Entry
if externalID := e.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.externalID); externalID != "" {
class.SetExternalId(externalID)
}
if classification := e.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.classification); classification != "" {
class.SetClassification(classification)
}
}
return class
}
func (i *LDAP) getEducationClassLDAPDN(class libregraph.EducationClass) string {
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: "qsferaEducationExternalId",
Value: class.GetExternalId(),
}
return fmt.Sprintf("%s,%s", attributeTypeAndValue.String(), i.groupBaseDN)
}
func (i *LDAP) getEducationClassByID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
return i.getEducationObjectByNameOrID(
nameOrID,
i.userAttributeMap.id,
i.educationConfig.classAttributeMap.externalID,
i.groupFilter,
i.educationConfig.classObjectClass,
i.groupBaseDN,
i.getEducationClassAttrTypes(requestMembers),
)
}
// GetEducationClassTeachers returns the EducationUser teachers for an EducationClass
func (i *LDAP) GetEducationClassTeachers(ctx context.Context, classID string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
class, err := i.getEducationClassByID(classID, false)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
return nil, err
}
teacherEntries, err := i.expandLDAPAttributeEntries(ctx, class, i.educationConfig.classAttributeMap.teachers, "")
result := make([]*libregraph.EducationUser, 0, len(teacherEntries))
if err != nil {
return nil, err
}
for _, teacher := range teacherEntries {
if u := i.createEducationUserModelFromLDAP(teacher); u != nil {
result = append(result, u)
}
}
return result, nil
}
// AddTeacherToEducationClass adds a teacher (by ID) to class in the identity backend.
func (i *LDAP) AddTeacherToEducationClass(ctx context.Context, classID string, teacherID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
class, err := i.getEducationClassByID(classID, false)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
return err
}
logger.Debug().Str("classDn", class.DN).Msg("got a class")
teacher, err := i.getEducationUserByNameOrID(teacherID)
if err != nil {
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
return err
}
logger.Debug().Str("userDn", teacher.DN).Msg("got a user")
mr := ldap.ModifyRequest{DN: class.DN}
// Handle empty teacher list
current := class.GetEqualFoldAttributeValues(i.educationConfig.classAttributeMap.teachers)
if len(current) == 1 && current[0] == "" {
mr.Delete(i.educationConfig.classAttributeMap.teachers, []string{""})
}
// Create a Set of current teachers
currentSet := make(map[string]struct{}, len(current))
for _, currentTeacher := range current {
if currentTeacher == "" {
continue
}
nCurrentTeacher, err := ldapdn.ParseNormalize(currentTeacher)
if err != nil {
// Couldn't parse teacher value as a DN, skipping
logger.Warn().Str("teacherDN", currentTeacher).Err(err).Msg("Couldn't parse DN")
continue
}
currentSet[nCurrentTeacher] = struct{}{}
}
var newTeacherDN []string
nDN, err := ldapdn.ParseNormalize(teacher.DN)
if err != nil {
logger.Error().Str("new teacher", teacher.DN).Err(err).Msg("Couldn't parse DN")
return err
}
if _, present := currentSet[nDN]; !present {
newTeacherDN = append(newTeacherDN, teacher.DN)
} else {
logger.Debug().Str("teacherDN", teacher.DN).Msg("Member already present in group. Skipping")
}
if len(newTeacherDN) > 0 {
mr.Add(i.educationConfig.classAttributeMap.teachers, newTeacherDN)
if err := i.conn.Modify(&mr); err != nil {
return err
}
}
return nil
}
// RemoveTeacherFromEducationClass removes teacher (by ID) from a class
func (i *LDAP) RemoveTeacherFromEducationClass(ctx context.Context, classID string, teacherID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
class, err := i.getEducationClassByID(classID, false)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
return err
}
teacher, err := i.getEducationUserByNameOrID(teacherID)
if err != nil {
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
return err
}
return i.removeEntryByDNAndAttributeFromEntry(class, teacher.DN, i.educationConfig.classAttributeMap.teachers)
}
@@ -0,0 +1,551 @@
package identity
import (
"context"
"errors"
"testing"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var classEntry = ldap.NewEntry("qsferaEducationExternalId=Math0123",
map[string][]string{
"cn": {"Math"},
"qsferaEducationExternalId": {"Math0123"},
"qsferaEducationClassType": {"course"},
"entryUUID": {"abcd-defg"},
})
var classEntryWithSchool = ldap.NewEntry("qsferaEducationExternalId=Math0123",
map[string][]string{
"cn": {"Math"},
"qsferaEducationExternalId": {"Math0123"},
"qsferaEducationClassType": {"course"},
"entryUUID": {"abcd-defg"},
"qsferaMemberOfSchool": {"abcd-defg"},
})
var classEntryWithMember = ldap.NewEntry("qsferaEducationExternalId=Math0123",
map[string][]string{
"cn": {"Math"},
"qsferaEducationExternalId": {"Math0123"},
"qsferaEducationClassType": {"course"},
"entryUUID": {"abcd-defg"},
"member": {"uid=user"},
})
func TestCreateEducationClass(t *testing.T) {
lm := &mocks.Client{}
lm.On("Add", mock.Anything).
Return(nil)
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
assert.NotEqual(t, "", b.educationConfig.classObjectClass)
class := libregraph.NewEducationClass("Math", "course")
class.SetExternalId("Math0123")
class.SetId("abcd-defg")
resClass, err := b.CreateEducationClass(context.Background(), *class)
lm.AssertNumberOfCalls(t, "Add", 1)
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
assert.NotNil(t, resClass)
assert.Equal(t, resClass.GetDisplayName(), class.GetDisplayName())
assert.Equal(t, resClass.GetId(), class.GetId())
assert.Equal(t, resClass.GetExternalId(), class.GetExternalId())
assert.Equal(t, resClass.GetClassification(), class.GetClassification())
}
func TestGetEducationClasses(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
b, _ := getMockedBackend(lm, lconfig, &logger)
_, err := b.GetEducationClasses(context.Background())
assert.ErrorContains(t, err, "itemNotFound:")
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err := b.GetEducationClasses(context.Background())
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
} else if g == nil || len(g) != 0 {
t.Errorf("Expected zero length user slice")
}
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err = b.GetEducationClasses(context.Background())
if err != nil {
t.Errorf("Expected GetEducationClasses to succeed. Got %s", err.Error())
} else if *g[0].Id != classEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
t.Errorf("Expected GetEducationClasses to return a valid group")
}
}
func TestGetEducationClass(t *testing.T) {
tests := []struct {
name string
id string
filter string
expectedItemNotFound bool
}{
{
name: "Test search class using id",
id: "abcd-defg",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
expectedItemNotFound: false,
},
{
name: "Test search class using unknown Id",
id: "xxxx-xxxx",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=xxxx-xxxx)(qsferaEducationExternalId=xxxx-xxxx)))",
expectedItemNotFound: true,
},
{
name: "Test search class using external ID",
id: "Math0123",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Math0123)(qsferaEducationExternalId=Math0123)))",
expectedItemNotFound: false,
},
{
name: "Test search school using unknown externalID",
id: "Unknown3210",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Unknown3210)(qsferaEducationExternalId=Unknown3210)))",
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
class, err := b.GetEducationClass(context.Background(), tt.id)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
assert.Equal(t, "Math", class.GetDisplayName())
assert.Equal(t, "abcd-defg", class.GetId())
assert.Equal(t, "Math0123", class.GetExternalId())
}
}
}
func TestDeleteEducationClass(t *testing.T) {
tests := []struct {
name string
id string
filter string
expectedItemNotFound bool
}{
{
name: "Test search class using id",
id: "abcd-defg",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
expectedItemNotFound: false,
},
{
name: "Test search class using unknown Id",
id: "xxxx-xxxx",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=xxxx-xxxx)(qsferaEducationExternalId=xxxx-xxxx)))",
expectedItemNotFound: true,
},
{
name: "Test search class using external ID",
id: "Math0123",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Math0123)(qsferaEducationExternalId=Math0123)))",
expectedItemNotFound: false,
},
{
name: "Test search school using unknown externalID",
id: "Unknown3210",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Unknown3210)(qsferaEducationExternalId=Unknown3210)))",
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
}
dr := &ldap.DelRequest{
DN: "qsferaEducationExternalId=Math0123",
}
lm.On("Del", dr).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.DeleteEducationClass(context.Background(), tt.id)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
lm.AssertNumberOfCalls(t, "Del", 0)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
}
}
}
func TestGetEducationClassMembers(t *testing.T) {
tests := []struct {
name string
id string
filter string
expectedItemNotFound bool
}{
{
name: "Test search class using id",
id: "abcd-defg",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
expectedItemNotFound: false,
},
{
name: "Test search class using unknown Id",
id: "xxxx-xxxx",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=xxxx-xxxx)(qsferaEducationExternalId=xxxx-xxxx)))",
expectedItemNotFound: true,
},
{
name: "Test search class using external ID",
id: "Math0123",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Math0123)(qsferaEducationExternalId=Math0123)))",
expectedItemNotFound: false,
},
{
name: "Test search school using unknown externalID",
id: "Unknown3210",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Unknown3210)(qsferaEducationExternalId=Unknown3210)))",
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
userSr := &ldap.SearchRequest{
BaseDN: "uid=user",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
lm.On("Search", userSr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
sr := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember", "member"},
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntryWithMember}}, nil)
}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
users, err := b.GetEducationClassMembers(context.Background(), tt.id)
if tt.expectedItemNotFound {
lm.AssertNumberOfCalls(t, "Search", 1)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
lm.AssertNumberOfCalls(t, "Search", 2)
assert.Nil(t, err)
assert.Equal(t, len(users), 1)
}
}
}
func TestLDAP_UpdateEducationClass(t *testing.T) {
externalIDs := []string{"Math3210"}
changeString := "xxxx-xxxx"
type args struct {
id string
class libregraph.EducationClass
}
type modifyData struct {
arg *ldap.ModifyRequest
ret error
}
type modifyDNData struct {
arg *ldap.ModifyDNRequest
ret error
}
type searchData struct {
res *ldap.SearchResult
err error
}
tests := []struct {
name string
args args
modifyDNData modifyDNData
modifyData modifyData
searchData searchData
assertion func(assert.TestingT, error, ...any) bool
}{
{
name: "Change name",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
DisplayName: "Math-2",
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Nil(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math0123",
Changes: []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "cn",
Vals: []string{"Math-2"},
},
},
},
},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Change external ID",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
ExternalId: &externalIDs[0],
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Nil(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{
DN: "qsferaEducationExternalId=Math0123",
NewRDN: "qsferaEducationExternalId=Math3210",
DeleteOldRDN: true,
NewSuperior: "",
},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Change both name and external ID",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
DisplayName: "Math-2",
ExternalId: &externalIDs[0],
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Nil(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math3210,ou=groups,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "cn",
Vals: []string{"Math-2"},
},
},
},
},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{
DN: "qsferaEducationExternalId=Math0123",
NewRDN: "qsferaEducationExternalId=Math3210",
DeleteOldRDN: true,
NewSuperior: "",
},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing ID",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Id: &changeString,
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing description",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Description: &changeString,
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing classification",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Classification: changeString,
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing members",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Members: []libregraph.User{*libregraph.NewUser("display name", "username")},
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
if err != nil {
panic(err)
}
lm.On("Modify", tt.modifyData.arg).Return(tt.modifyData.ret)
lm.On("ModifyDN", tt.modifyDNData.arg).Return(tt.modifyDNData.ret)
lm.On("Search", mock.Anything).Return(tt.searchData.res, tt.searchData.err)
ctx := context.Background()
_, err = b.UpdateEducationClass(ctx, tt.args.id, tt.args.class)
tt.assertion(t, err)
})
}
}
@@ -0,0 +1,812 @@
package identity
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type educationConfig struct {
schoolBaseDN string
schoolFilter string
schoolObjectClass string
schoolScope int
// memberOfSchoolAttribute defines the AttributeType on the user/group objects
// which contains the school Id to which the user/group is assigned
memberOfSchoolAttribute string
schoolAttributeMap schoolAttributeMap
userObjectClass string
userAttributeMap educationUserAttributeMap
classObjectClass string
classAttributeMap educationClassAttributeMap
}
type schoolAttributeMap struct {
displayName string
schoolNumber string
externalId string
id string
terminationDate string
}
type schoolUpdateOperation uint8
const (
tooManyValues schoolUpdateOperation = iota
schoolUnchanged
schoolRenamed
schoolPropertiesUpdated
)
var (
errNotSet = errors.New("attribute not set")
errSchoolNameExists = errorcode.New(errorcode.NameAlreadyExists, "A school with that name is already present")
errSchoolNumberExists = errorcode.New(errorcode.NameAlreadyExists, "A school with that number is already present")
)
func defaultEducationConfig() educationConfig {
return educationConfig{
schoolObjectClass: "qsferaEducationSchool",
schoolScope: ldap.ScopeWholeSubtree,
memberOfSchoolAttribute: "qsferaMemberOfSchool",
schoolAttributeMap: newSchoolAttributeMap(),
userObjectClass: "qsferaEducationUser",
userAttributeMap: newEducationUserAttributeMap(),
classObjectClass: "qsferaEducationClass",
classAttributeMap: newEducationClassAttributeMap(),
}
}
func newEducationConfig(config config.LDAP) (educationConfig, error) {
if config.EducationResourcesEnabled {
var err error
eduCfg := defaultEducationConfig()
eduCfg.schoolBaseDN = config.EducationConfig.SchoolBaseDN
if config.EducationConfig.SchoolSearchScope != "" {
if eduCfg.schoolScope, err = stringToScope(config.EducationConfig.SchoolSearchScope); err != nil {
return educationConfig{}, fmt.Errorf("error configuring school search scope: %w", err)
}
}
if config.EducationConfig.SchoolFilter != "" {
eduCfg.schoolFilter = config.EducationConfig.SchoolFilter
}
if config.EducationConfig.SchoolObjectClass != "" {
eduCfg.schoolObjectClass = config.EducationConfig.SchoolObjectClass
}
// Attribute mapping config
if config.EducationConfig.SchoolNameAttribute != "" {
eduCfg.schoolAttributeMap.displayName = config.EducationConfig.SchoolNameAttribute
}
if config.EducationConfig.SchoolNumberAttribute != "" {
eduCfg.schoolAttributeMap.schoolNumber = config.EducationConfig.SchoolNumberAttribute
}
if config.EducationConfig.SchoolIDAttribute != "" {
eduCfg.schoolAttributeMap.id = config.EducationConfig.SchoolIDAttribute
}
return eduCfg, nil
}
return educationConfig{}, nil
}
func newSchoolAttributeMap() schoolAttributeMap {
return schoolAttributeMap{
displayName: "ou",
schoolNumber: "qsferaEducationSchoolNumber",
externalId: "qsferaEducationExternalId",
id: "qsferaUUID",
terminationDate: "qsferaEducationSchoolTerminationTimestamp",
}
}
// CreateEducationSchool creates the supplied school in the identity backend.
func (i *LDAP) CreateEducationSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("CreateEducationSchool")
if !i.writeEnabled {
return nil, ErrReadOnly
}
// Check that the school number is not already used
if school.HasSchoolNumber() {
_, err := i.getSchoolByNumber(school.GetSchoolNumber())
switch {
case err == nil:
logger.Debug().Err(errSchoolNumberExists).Str("schoolNumber", school.GetSchoolNumber()).Msg("duplicate school number")
return nil, errSchoolNumberExists
case errors.Is(err, ErrNotFound):
break
default:
logger.Error().Err(err).Str("schoolNumber", school.GetSchoolNumber()).Msg("error looking up school by number")
return nil, errorcode.New(errorcode.GeneralException, "error looking up school by number")
}
}
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.educationConfig.schoolAttributeMap.displayName,
Value: school.GetDisplayName(),
}
dn := fmt.Sprintf("%s,%s",
attributeTypeAndValue.String(),
i.educationConfig.schoolBaseDN,
)
ar := ldap.NewAddRequest(dn, nil)
ar.Attribute(i.educationConfig.schoolAttributeMap.displayName, []string{school.GetDisplayName()})
if school.HasSchoolNumber() {
ar.Attribute(i.educationConfig.schoolAttributeMap.schoolNumber, []string{school.GetSchoolNumber()})
}
if school.HasExternalId() {
ar.Attribute(i.educationConfig.schoolAttributeMap.externalId, []string{school.GetExternalId()})
}
if !i.useServerUUID {
ar.Attribute(i.educationConfig.schoolAttributeMap.id, []string{uuid.NewString()})
}
objectClasses := []string{"organizationalUnit", i.educationConfig.schoolObjectClass, "top"}
ar.Attribute("objectClass", objectClasses)
if err := i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error adding school")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errSchoolNameExists
}
}
return nil, err
}
// Read back school from LDAP to get the generated UUID
e, err := i.getSchoolByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createSchoolModelFromLDAP(e), nil
}
// UpdateEducationSchoolOperation contains the logic for which update operation to apply to a school
func (i *LDAP) updateEducationSchoolOperation(
schoolUpdate libregraph.EducationSchool,
currentSchool libregraph.EducationSchool,
) schoolUpdateOperation {
providedDisplayName, displayNameIsSet := schoolUpdate.GetDisplayNameOk()
if displayNameIsSet {
if *providedDisplayName == "" || *providedDisplayName == currentSchool.GetDisplayName() {
// The school name hasn't changed
displayNameIsSet = false
}
}
var propertiesUpdated bool
switch {
case schoolUpdate.HasSchoolNumber():
if schoolUpdate.GetSchoolNumber() != "" && schoolUpdate.GetSchoolNumber() != currentSchool.GetSchoolNumber() {
propertiesUpdated = true
}
case schoolUpdate.HasTerminationDate():
if schoolUpdate.GetTerminationDate() != currentSchool.GetTerminationDate() {
propertiesUpdated = true
}
}
if propertiesUpdated && displayNameIsSet {
return tooManyValues
}
if displayNameIsSet {
return schoolRenamed
}
if propertiesUpdated {
return schoolPropertiesUpdated
}
return schoolUnchanged
}
// updateDisplayName updates the school OU in the identity backend
func (i *LDAP) updateDisplayName(ctx context.Context, dn string, providedDisplayName string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.educationConfig.schoolAttributeMap.displayName,
Value: providedDisplayName,
}
mrdn := ldap.NewModifyDNRequest(dn, attributeTypeAndValue.String(), true, "")
i.logger.Debug().Str("backend", "ldap").
Str("dn", mrdn.DN).
Str("newrdn", mrdn.NewRDN).
Msg("updateDisplayName")
if err := i.conn.ModifyDN(mrdn); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error updating school name")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errSchoolNameExists
}
}
return err
}
return nil
}
// updateSchoolProperties updates the properties (other that displayName) of a school.
// It checks if a school number is already taken, before updating the school number
func (i *LDAP) updateSchoolProperties(ctx context.Context, dn string, currentSchool, updatedSchool libregraph.EducationSchool) error {
logger := i.logger.SubloggerWithRequestID(ctx)
mr := ldap.NewModifyRequest(dn, nil)
if updatedSchoolNumber, ok := updatedSchool.GetSchoolNumberOk(); ok {
if *updatedSchoolNumber != "" && currentSchool.GetSchoolNumber() != *updatedSchoolNumber {
_, err := i.getSchoolByNumber(*updatedSchoolNumber)
if err == nil {
return errSchoolNumberExists
}
mr.Replace(i.educationConfig.schoolAttributeMap.schoolNumber, []string{*updatedSchoolNumber})
}
}
if updatedTerminationDate, ok := updatedSchool.GetTerminationDateOk(); ok {
if updatedTerminationDate == nil && currentSchool.HasTerminationDate() {
// Delete the termination date
mr.Delete(i.educationConfig.schoolAttributeMap.terminationDate, []string{})
}
if updatedTerminationDate != nil && *updatedTerminationDate != currentSchool.GetTerminationDate() {
ldapDateTime := updatedTerminationDate.UTC().Format(ldapDateFormat)
mr.Replace(i.educationConfig.schoolAttributeMap.terminationDate, []string{ldapDateTime})
}
}
if err := i.conn.Modify(mr); err != nil {
logger.Debug().Err(err).Msg("error updating school number")
return err
}
return nil
}
// UpdateEducationSchool updates the supplied school in the identity backend
func (i *LDAP) UpdateEducationSchool(ctx context.Context, numberOrID string, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationSchool")
if !i.writeEnabled {
return nil, ErrReadOnly
}
e, err := i.getSchoolByNumberOrID(numberOrID)
if err != nil {
return nil, err
}
currentSchool := i.createSchoolModelFromLDAP(e)
switch i.updateEducationSchoolOperation(school, *currentSchool) {
case tooManyValues:
return nil, fmt.Errorf("school name and school number cannot be updated in the same request")
case schoolUnchanged:
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationSchool: Nothing changed")
return currentSchool, nil
case schoolRenamed:
if err := i.updateDisplayName(ctx, e.DN, school.GetDisplayName()); err != nil {
return nil, err
}
case schoolPropertiesUpdated:
if err := i.updateSchoolProperties(ctx, e.DN, *currentSchool, school); err != nil {
return nil, err
}
}
// Read back school from LDAP
e, err = i.getSchoolByNumberOrID(i.getID(e))
if err != nil {
return nil, err
}
return i.createSchoolModelFromLDAP(e), nil
}
// DeleteEducationSchool deletes a given school, identified by id
func (i *LDAP) DeleteEducationSchool(ctx context.Context, id string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteEducationSchool")
if !i.writeEnabled {
return ErrReadOnly
}
e, err := i.getSchoolByNumberOrID(id)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
// TODO update any users that are member of this school
return nil
}
// GetEducationSchool implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchool(ctx context.Context, numberOrID string) (*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationSchool")
e, err := i.getSchoolByNumberOrID(numberOrID)
if err != nil {
return nil, err
}
return i.createSchoolModelFromLDAP(e), nil
}
// GetEducationSchools implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchools(ctx context.Context) ([]*libregraph.EducationSchool, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.schoolObjectClass)
if i.educationConfig.schoolFilter != "" {
filter = fmt.Sprintf("(&%s%s)", i.educationConfig.schoolFilter, filter)
}
return i.searchEducationSchools(ctx, filter)
}
// FilterEducationSchoolsByAttribute implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) FilterEducationSchoolsByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx).With().Str("func", "FilterEducationSchoolsByAttribute").Logger()
logger.Debug().Str("backend", "ldap").Str("attribute", attr).Str("value", value).Msg("")
var ldapAttr string
switch attr {
case "externalId":
ldapAttr = i.educationConfig.schoolAttributeMap.externalId
default:
return nil, errorcode.New(errorcode.InvalidRequest, fmt.Sprintf("filtering by attribute '%s' is not supported", attr))
}
filter := fmt.Sprintf("(&%s(objectClass=%s)(%s=%s))",
i.educationConfig.schoolFilter,
i.educationConfig.schoolObjectClass,
ldap.EscapeFilter(ldapAttr),
ldap.EscapeFilter(value),
)
return i.searchEducationSchools(ctx, filter)
}
// searchEducationSchools builds and executes an LDAP search for education schools and converts the results to EducationSchool models.
func (i *LDAP) searchEducationSchools(ctx context.Context, filter string) ([]*libregraph.EducationSchool, error) {
searchRequest := ldap.NewSearchRequest(
i.educationConfig.schoolBaseDN,
i.educationConfig.schoolScope,
ldap.NeverDerefAliases, 0, 0, false,
filter,
i.getEducationSchoolAttrTypes(),
nil,
)
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("searchEducationSchools")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
schools := make([]*libregraph.EducationSchool, 0, len(res.Entries))
for _, e := range res.Entries {
school := i.createSchoolModelFromLDAP(e)
// Skip invalid LDAP entries
if school == nil {
continue
}
schools = append(schools, school)
}
return schools, nil
}
// GetEducationSchoolUsers implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchoolUsers(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationSchoolUsers")
entries, err := i.getEducationSchoolEntries(
schoolNumberOrID, i.userFilter, i.educationConfig.userObjectClass, i.userBaseDN, i.userScope, i.getEducationUserAttrTypes(), logger,
)
if err != nil {
return nil, err
}
users := make([]*libregraph.EducationUser, 0, len(entries))
for _, e := range entries {
u := i.createEducationUserModelFromLDAP(e)
// Skip invalid LDAP users
if u == nil {
continue
}
users = append(users, u)
}
return users, nil
}
// AddUsersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
func (i *LDAP) AddUsersToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddUsersToEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
userEntries := make([]*ldap.Entry, 0, len(memberIDs))
for _, memberID := range memberIDs {
user, err := i.getEducationUserByNameOrID(memberID)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("User does not exist")
return errorcode.New(errorcode.ItemNotFound, fmt.Sprintf("user '%s' not found", memberID))
}
userEntries = append(userEntries, user)
}
for _, userEntry := range userEntries {
if err := i.addEntryToSchool(userEntry, schoolID); err != nil {
return err
}
}
return nil
}
// addEntryToSchool adds the schoolID to the entry's memberOfSchool attribute if not already present.
func (i *LDAP) addEntryToSchool(entry *ldap.Entry, schoolID string) error {
currentSchools := entry.GetEqualFoldAttributeValues(i.educationConfig.memberOfSchoolAttribute)
if slices.Contains(currentSchools, schoolID) {
return nil
}
mr := ldap.ModifyRequest{DN: entry.DN}
mr.Add(i.educationConfig.memberOfSchoolAttribute, []string{schoolID})
return i.conn.Modify(&mr)
}
// RemoveUserFromEducationSchool removes a single member (by ID) from a school
func (i *LDAP) RemoveUserFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("RemoveUserFromEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
user, err := i.getEducationUserByNameOrID(memberID)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("User does not exist")
return err
}
currentSchools := user.GetEqualFoldAttributeValues(i.educationConfig.memberOfSchoolAttribute)
for _, currentSchool := range currentSchools {
if currentSchool == schoolID {
mr := ldap.ModifyRequest{DN: user.DN}
mr.Delete(i.educationConfig.memberOfSchoolAttribute, []string{schoolID})
if err := i.conn.Modify(&mr); err != nil {
return err
}
break
}
}
return nil
}
// GetEducationSchoolClasses implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchoolClasses(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationSchoolClasses")
entries, err := i.getEducationSchoolEntries(
schoolNumberOrID, i.groupFilter, i.educationConfig.classObjectClass, i.groupBaseDN, i.groupScope, i.getEducationClassAttrTypes(false), logger,
)
if err != nil {
return nil, err
}
classes := make([]*libregraph.EducationClass, 0, len(entries))
for _, e := range entries {
class := i.createEducationClassModelFromLDAP(e)
// Skip invalid LDAP classes
if class == nil {
continue
}
classes = append(classes, class)
}
return classes, nil
}
func (i *LDAP) getEducationSchoolEntries(
schoolNumberOrID, filter, objectClass, baseDN string,
scope int,
attributes []string,
logger log.Logger,
) ([]*ldap.Entry, error) {
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return nil, err
}
if schoolEntry == nil {
return nil, ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
schoolID = ldap.EscapeFilter(schoolID)
idFilter := fmt.Sprintf("(%s=%s)", i.educationConfig.memberOfSchoolAttribute, schoolID)
searchFilter := fmt.Sprintf("(&%s(objectClass=%s)%s)", filter, objectClass, idFilter)
searchRequest := ldap.NewSearchRequest(
baseDN,
scope,
ldap.NeverDerefAliases, 0, 0, false,
searchFilter,
attributes,
nil,
)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetEducationClasses")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
return res.Entries, nil
}
// AddClassesToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
func (i *LDAP) AddClassesToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddClassesToEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
classEntries := make([]*ldap.Entry, 0, len(memberIDs))
for _, memberID := range memberIDs {
class, err := i.getEducationClassByID(memberID, false)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("Class does not exist")
return err
}
classEntries = append(classEntries, class)
}
for _, classEntry := range classEntries {
if err := i.addEntryToSchool(classEntry, schoolID); err != nil {
return err
}
}
return nil
}
// RemoveClassFromEducationSchool removes a single member (by ID) from a school
func (i *LDAP) RemoveClassFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("RemoveClassFromEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
class, err := i.getEducationClassByID(memberID, false)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("Class does not exist")
return err
}
currentSchools := class.GetEqualFoldAttributeValues(i.educationConfig.memberOfSchoolAttribute)
for _, currentSchool := range currentSchools {
if currentSchool == schoolID {
mr := ldap.ModifyRequest{DN: class.DN}
mr.Delete(i.educationConfig.memberOfSchoolAttribute, []string{schoolID})
if err := i.conn.Modify(&mr); err != nil {
return err
}
break
}
}
return nil
}
func (i *LDAP) getSchoolByDN(dn string) (*ldap.Entry, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.schoolObjectClass)
if i.educationConfig.schoolFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.educationConfig.schoolFilter)
}
return i.getEntryByDN(dn, i.getEducationSchoolAttrTypes(), filter)
}
func (i *LDAP) getSchoolByNumberOrID(numberOrID string) (*ldap.Entry, error) {
numberOrID = ldap.EscapeFilter(numberOrID)
filter := fmt.Sprintf(
"(|(%s=%s)(%s=%s))",
i.educationConfig.schoolAttributeMap.id,
numberOrID,
i.educationConfig.schoolAttributeMap.schoolNumber,
numberOrID,
)
return i.getSchoolByFilter(filter)
}
func (i *LDAP) getSchoolByNumber(schoolNumber string) (*ldap.Entry, error) {
schoolNumber = ldap.EscapeFilter(schoolNumber)
filter := fmt.Sprintf(
"(%s=%s)",
i.educationConfig.schoolAttributeMap.schoolNumber,
schoolNumber,
)
return i.getSchoolByFilter(filter)
}
func (i *LDAP) getSchoolByFilter(filter string) (*ldap.Entry, error) {
filter = fmt.Sprintf("(&%s(objectClass=%s)%s)",
i.educationConfig.schoolFilter,
i.educationConfig.schoolObjectClass,
filter,
)
searchRequest := ldap.NewSearchRequest(
i.educationConfig.schoolBaseDN,
i.educationConfig.schoolScope,
ldap.NeverDerefAliases, 1, 0, false,
filter,
i.getEducationSchoolAttrTypes(),
nil,
)
i.logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("getSchoolByFilter")
res, err := i.conn.Search(searchRequest)
if err != nil {
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for school '%s'", filter)
i.logger.Debug().Str("backend", "ldap").Err(lerr).
Str("schoolfilter", filter).Msg("too many results searching for school")
}
}
return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
}
if len(res.Entries) == 0 {
return nil, ErrNotFound
}
return res.Entries[0], nil
}
func (i *LDAP) createSchoolModelFromLDAP(e *ldap.Entry) *libregraph.EducationSchool {
if e == nil {
return nil
}
displayName := i.getDisplayName(e)
id := i.getID(e)
schoolNumber := i.getSchoolNumber(e)
externalId := i.getExternalId(e)
t, err := i.getTerminationDate(e)
if err != nil && !errors.Is(err, errNotSet) {
i.logger.Error().Err(err).Str("dn", e.DN).Msg("Error reading termination date for LDAP entry")
}
if id == "" || displayName == "" {
i.logger.Warn().Str("dn", e.DN).Str("id", id).Str("displayName", displayName).Msg("Invalid School. Missing required attribute")
return nil
}
school := libregraph.NewEducationSchool()
school.SetDisplayName(displayName)
school.SetId(id)
if schoolNumber != "" {
school.SetSchoolNumber(schoolNumber)
}
if externalId != "" {
school.SetExternalId(externalId)
}
if t != nil {
school.SetTerminationDate(*t)
}
return school
}
func (i *LDAP) getSchoolNumber(e *ldap.Entry) string {
schoolNumber := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.schoolNumber)
return schoolNumber
}
func (i *LDAP) getExternalId(e *ldap.Entry) string {
externalId := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.externalId)
return externalId
}
func (i *LDAP) getID(e *ldap.Entry) string {
id := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
return id
}
func (i *LDAP) getDisplayName(e *ldap.Entry) string {
displayName := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.displayName)
return displayName
}
func (i *LDAP) getTerminationDate(e *ldap.Entry) (*time.Time, error) {
dateString := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.terminationDate)
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) getEducationSchoolAttrTypes() []string {
return []string{
i.educationConfig.schoolAttributeMap.displayName,
i.educationConfig.schoolAttributeMap.id,
i.educationConfig.schoolAttributeMap.externalId,
i.educationConfig.schoolAttributeMap.schoolNumber,
i.educationConfig.schoolAttributeMap.terminationDate,
}
}
@@ -0,0 +1,722 @@
package identity
import (
"context"
"errors"
"testing"
"time"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var eduConfig = config.LDAP{
UserBaseDN: "ou=people,dc=test",
UserObjectClass: "inetOrgPerson",
UserSearchScope: "sub",
UserFilter: "",
UserDisplayNameAttribute: "displayname",
UserIDAttribute: "entryUUID",
UserEmailAttribute: "mail",
UserNameAttribute: "uid",
UserEnabledAttribute: "userEnabledAttribute",
DisableUserMechanism: "attribute",
UserTypeAttribute: "userTypeAttribute",
GroupBaseDN: "ou=groups,dc=test",
GroupObjectClass: "groupOfNames",
GroupSearchScope: "sub",
GroupFilter: "",
GroupNameAttribute: "cn",
GroupMemberAttribute: "member",
GroupIDAttribute: "entryUUID",
WriteEnabled: true,
EducationResourcesEnabled: true,
}
var schoolEntry = ldap.NewEntry("ou=Test School",
map[string][]string{
"ou": {"Test School"},
"qsferaEducationSchoolNumber": {"0123"},
"qsferaUUID": {"abcd-defg"},
"qsferaEducationExternalId": {"abcd-defg"},
})
var schoolEntry1 = ldap.NewEntry("ou=Test School1",
map[string][]string{
"ou": {"Test School1"},
"qsferaEducationSchoolNumber": {"0042"},
"qsferaUUID": {"hijk-defg"},
"qsferaEducationExternalId": {"hijk-defg"},
})
var schoolEntryWithTermination = ldap.NewEntry("ou=Test School",
map[string][]string{
"ou": {"Test School"},
"qsferaEducationSchoolNumber": {"0123"},
"qsferaUUID": {"abcd-defg"},
"qsferaEducationExternalId": {"abcd-defg"},
"qsferaEducationSchoolTerminationTimestamp": {"20420131120000Z"},
})
var (
filterSchoolSearchByIdExisting = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=abcd-defg)(qsferaEducationSchoolNumber=abcd-defg)))"
filterSchoolSearchByIdNonexistant = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=xxxx-xxxx)(qsferaEducationSchoolNumber=xxxx-xxxx)))"
filterSchoolSearchByNumberExisting = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=0123)(qsferaEducationSchoolNumber=0123)))"
filterSchoolSearchByNumberNonexistant = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=3210)(qsferaEducationSchoolNumber=3210)))"
schoolLDAPAttributeTypes = []string{"ou", "qsferaUUID", "qsferaEducationExternalId", "qsferaEducationSchoolNumber", "qsferaEducationSchoolTerminationTimestamp"}
)
func TestCreateEducationSchool(t *testing.T) {
tests := []struct {
name string
schoolNumber string
schoolName string
expectedError error
}{
{
name: "Create a Education School succeeds",
schoolNumber: "0123",
schoolName: "Test School",
expectedError: nil,
}, {
name: "Create a Education School with a duplicated Schoolnumber fails with an error",
schoolNumber: "0666",
schoolName: "Test School",
expectedError: errorcode.New(errorcode.NameAlreadyExists, "A school with that number is already present"),
}, {
name: "Create a Education School with a duplicated Name fails with an error",
schoolNumber: "0123",
schoolName: "Existing Test School",
expectedError: errorcode.New(errorcode.NameAlreadyExists, "A school with that name is already present"),
}, {
name: "Create a Education School fails, when there is a backend error",
schoolNumber: "1111",
schoolName: "Test School",
expectedError: errorcode.New(errorcode.GeneralException, "error looking up school by number"),
},
}
for _, tt := range tests {
lm := &mocks.Client{}
ldapSchoolGoodAddRequestMatcher := func(ar *ldap.AddRequest) bool {
if ar.DN != "ou=Test School," {
return false
}
for _, attr := range ar.Attributes {
if attr.Type == "qsferaEducationSchoolTerminationTimestamp" {
return false
}
}
return true
}
lm.On("Add", mock.MatchedBy(ldapSchoolGoodAddRequestMatcher)).Return(nil)
ldapExistingSchoolAddRequestMatcher := func(ar *ldap.AddRequest) bool {
if ar.DN == "ou=Existing Test School," {
return true
}
return false
}
lm.On("Add", mock.MatchedBy(ldapExistingSchoolAddRequestMatcher)).Return(ldap.NewError(ldap.LDAPResultEntryAlreadyExists, errors.New("")))
schoolNumberSearchRequest := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationSchool)(qsferaEducationSchoolNumber=0123))",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", schoolNumberSearchRequest).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{},
},
nil)
existingSchoolNumberSearchRequest := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationSchool)(qsferaEducationSchoolNumber=0666))",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", existingSchoolNumberSearchRequest).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntry},
},
nil)
schoolNumberSearchRequestError := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationSchool)(qsferaEducationSchoolNumber=1111))",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", schoolNumberSearchRequestError).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{},
},
ldap.NewError(ldap.LDAPResultOther, errors.New("some error")))
schoolLookupAfterCreate := &ldap.SearchRequest{
BaseDN: "ou=Test School,",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=qsferaEducationSchool)",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", schoolLookupAfterCreate).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntry},
},
nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
school := libregraph.NewEducationSchool()
school.SetDisplayName(tt.schoolName)
school.SetSchoolNumber(tt.schoolNumber)
school.SetId("abcd-defg")
resSchool, err := b.CreateEducationSchool(context.Background(), *school)
if tt.expectedError == nil {
assert.Nil(t, err)
lm.AssertNumberOfCalls(t, "Add", 1)
assert.NotNil(t, resSchool)
assert.Equal(t, resSchool.GetDisplayName(), school.GetDisplayName())
assert.Equal(t, resSchool.GetId(), school.GetId())
assert.Equal(t, resSchool.GetSchoolNumber(), school.GetSchoolNumber())
assert.False(t, resSchool.HasTerminationDate())
} else {
assert.Equal(t, err, tt.expectedError)
assert.Nil(t, resSchool)
}
}
}
func TestUpdateEducationSchoolTerminationDate(t *testing.T) {
lm := &mocks.Client{}
ldapSchoolTerminationRequestMatcher := func(mr *ldap.ModifyRequest) bool {
if mr.DN != "ou=Test School" {
return false
}
for _, mod := range mr.Changes {
if mod.Operation == ldap.ReplaceAttribute &&
mod.Modification.Type == "qsferaEducationSchoolTerminationTimestamp" &&
mod.Modification.Vals[0] == "20420131120000Z" {
return true
}
}
return false
}
lm.On("Modify", mock.MatchedBy(ldapSchoolTerminationRequestMatcher)).Return(nil)
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntry},
},
nil).
Once()
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntryWithTermination},
},
nil).
Once()
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
school := libregraph.NewEducationSchool()
terminationTime := time.Date(2042, time.January, 31, 12, 0, 0, 0, time.UTC)
school.SetTerminationDate(terminationTime)
resSchool, err := b.UpdateEducationSchool(context.Background(), "abcd-defg", *school)
lm.AssertNumberOfCalls(t, "Search", 2)
assert.Nil(t, err)
assert.NotNil(t, resSchool)
assert.Equal(t, "Test School", resSchool.GetDisplayName())
assert.Equal(t, "abcd-defg", resSchool.GetId())
assert.Equal(t, "0123", resSchool.GetSchoolNumber())
assert.True(t, resSchool.HasTerminationDate())
assert.True(t, terminationTime.Equal(resSchool.GetTerminationDate()))
}
func TestUpdateEducationSchoolOperation(t *testing.T) {
testSchoolName := "A name"
testSchoolNumber := "1234"
tests := []struct {
name string
displayName string
schoolNumber string
expectedOperation schoolUpdateOperation
}{
{
name: "Test using school with both number and name, unchanged",
displayName: testSchoolName,
schoolNumber: testSchoolNumber,
expectedOperation: schoolUnchanged,
},
{
name: "Test using school with both number and name, unchanged",
displayName: "A new name",
schoolNumber: "9876",
expectedOperation: tooManyValues,
},
{
name: "Test with unchanged number",
schoolNumber: testSchoolNumber,
expectedOperation: schoolUnchanged,
},
{
name: "Test with unchanged name",
displayName: testSchoolName,
expectedOperation: schoolUnchanged,
},
{
name: "Test new name",
displayName: "Something new",
expectedOperation: schoolRenamed,
},
{
name: "Test new number",
schoolNumber: "9876",
expectedOperation: schoolPropertiesUpdated,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
displayName := "A name"
schoolNumber := "1234"
currentSchool := libregraph.EducationSchool{
DisplayName: &displayName,
SchoolNumber: &schoolNumber,
}
schoolUpdate := libregraph.EducationSchool{
DisplayName: &tt.displayName,
SchoolNumber: &tt.schoolNumber,
}
operation := b.updateEducationSchoolOperation(schoolUpdate, currentSchool)
assert.Equal(t, tt.expectedOperation, operation)
}
}
func TestDeleteEducationSchool(t *testing.T) {
tests := []struct {
name string
numberOrId string
filter string
expectedItemNotFound bool
}{
{
name: "Test delete school using schoolId",
numberOrId: "abcd-defg",
filter: filterSchoolSearchByIdExisting,
expectedItemNotFound: false,
},
{
name: "Test delete school using unknown schoolId",
numberOrId: "xxxx-xxxx",
filter: filterSchoolSearchByIdNonexistant,
expectedItemNotFound: true,
},
{
name: "Test delete school using schoolNumber",
numberOrId: "0123",
filter: filterSchoolSearchByNumberExisting,
expectedItemNotFound: false,
},
{
name: "Test delete school using unknown schoolNumber",
numberOrId: "3210",
filter: filterSchoolSearchByNumberNonexistant,
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
}
dr := &ldap.DelRequest{
DN: "ou=Test School",
}
lm.On("Del", dr).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.DeleteEducationSchool(context.Background(), tt.numberOrId)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
lm.AssertNumberOfCalls(t, "Del", 0)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
}
}
}
func TestGetEducationSchool(t *testing.T) {
tests := []struct {
name string
numberOrId string
filter string
expectedItemNotFound bool
}{
{
name: "Test search school using schoolId",
numberOrId: "abcd-defg",
filter: filterSchoolSearchByIdExisting,
expectedItemNotFound: false,
},
{
name: "Test search school using unknown schoolId",
numberOrId: "xxxx-xxxx",
filter: filterSchoolSearchByIdNonexistant,
expectedItemNotFound: true,
},
{
name: "Test search school using schoolNumber",
numberOrId: "0123",
filter: filterSchoolSearchByNumberExisting,
expectedItemNotFound: false,
},
{
name: "Test search school using unknown schoolNumber",
numberOrId: "3210",
filter: filterSchoolSearchByNumberNonexistant,
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
school, err := b.GetEducationSchool(context.Background(), tt.numberOrId)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
assert.Equal(t, "Test School", school.GetDisplayName())
assert.Equal(t, "abcd-defg", school.GetId())
assert.Equal(t, "0123", school.GetSchoolNumber())
}
}
}
func TestGetEducationSchools(t *testing.T) {
lm := &mocks.Client{}
sr1 := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 0,
Filter: "(objectClass=qsferaEducationSchool)",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry, schoolEntry1}}, nil)
// lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
_, err = b.GetEducationSchools(context.Background())
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
}
var schoolByIDSearch1 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: filterSchoolSearchByIdExisting,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
var schoolByNumberSearch *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: filterSchoolSearchByNumberExisting,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
var userByIDSearch1 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=people,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=abcd-defg)(entryUUID=abcd-defg)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
var userByIDSearch2 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=people,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=does-not-exist)(entryUUID=does-not-exist)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
var userToSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "uid=user,ou=people,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.AddAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
var userFromSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "uid=user,ou=people,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.DeleteAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
var classToSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math0123",
Changes: []ldap.Change{
{
Operation: ldap.AddAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
var classFromSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math0123",
Changes: []ldap.Change{
{
Operation: ldap.DeleteAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
func TestAddUsersToEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", userByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
lm.On("Search", userByIDSearch2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Modify", userToSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.AddUsersToEducationSchool(context.Background(), "abcd-defg", []string{"does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
err = b.AddUsersToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg", "does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 5)
assert.NotNil(t, err)
err = b.AddUsersToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 7)
assert.Nil(t, err)
// try to add by school number (instead or id)
err = b.AddUsersToEducationSchool(context.Background(), "0123", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 9)
assert.Nil(t, err)
}
func TestRemoveMemberFromEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", userByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntryWithSchool}}, nil)
lm.On("Search", userByIDSearch2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Modify", userFromSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.RemoveUserFromEducationSchool(context.Background(), "abcd-defg", "does-not-exist")
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
err = b.RemoveUserFromEducationSchool(context.Background(), "abcd-defg", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 4)
lm.AssertNumberOfCalls(t, "Modify", 1)
// try to remove by school number (instead or id)
err = b.RemoveUserFromEducationSchool(context.Background(), "0123", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 6)
lm.AssertNumberOfCalls(t, "Modify", 2)
assert.Nil(t, err)
}
var usersBySchoolIDSearch *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=people,dc=test",
Scope: 2,
SizeLimit: 0,
Filter: "(&(objectClass=qsferaEducationUser)(qsferaMemberOfSchool=abcd-defg))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
func TestGetEducationSchoolUsers(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", usersBySchoolIDSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntryWithSchool}}, nil)
b, _ := getMockedBackend(lm, eduConfig, &logger)
users, err := b.GetEducationSchoolUsers(context.Background(), "abcd-defg")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
users, err = b.GetEducationSchoolUsers(context.Background(), "0123")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
}
var classesBySchoolIDSearch *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 0,
Filter: "(&(objectClass=qsferaEducationClass)(qsferaMemberOfSchool=abcd-defg))",
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
func TestGetEducationSchoolClasses(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", classesBySchoolIDSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
b, _ := getMockedBackend(lm, eduConfig, &logger)
users, err := b.GetEducationSchoolClasses(context.Background(), "abcd-defg")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
users, err = b.GetEducationSchoolClasses(context.Background(), "0123")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
}
var classesByUUIDSearchNotFound *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=does-not-exist)(qsferaEducationExternalId=does-not-exist)))",
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
var classesByUUIDSearchFound *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
func TestAddClassesToEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", classesByUUIDSearchNotFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Search", classesByUUIDSearchFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Modify", classToSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.AddClassesToEducationSchool(context.Background(), "abcd-defg", []string{"does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
err = b.AddClassesToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg", "does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 5)
assert.NotNil(t, err)
err = b.AddClassesToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 7)
assert.Nil(t, err)
// try to add by school number (instead or id)
err = b.AddClassesToEducationSchool(context.Background(), "0123", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 9)
assert.Nil(t, err)
}
func TestRemoveClassFromEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", classesByUUIDSearchFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntryWithSchool}}, nil)
lm.On("Search", classesByUUIDSearchNotFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Modify", classFromSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.RemoveClassFromEducationSchool(context.Background(), "abcd-defg", "does-not-exist")
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
err = b.RemoveClassFromEducationSchool(context.Background(), "abcd-defg", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 4)
lm.AssertNumberOfCalls(t, "Modify", 1)
// try to remove by school number (instead or id)
err = b.RemoveClassFromEducationSchool(context.Background(), "0123", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 6)
lm.AssertNumberOfCalls(t, "Modify", 2)
assert.Nil(t, err)
}
@@ -0,0 +1,400 @@
package identity
import (
"context"
"errors"
"fmt"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type educationUserAttributeMap struct {
primaryRole string
externalID string
}
func newEducationUserAttributeMap() educationUserAttributeMap {
return educationUserAttributeMap{
primaryRole: "userClass",
externalID: "qsferaEducationExternalId",
}
}
// CreateEducationUser creates a given education user in the identity backend.
func (i *LDAP) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("CreateEducationUser")
if !i.writeEnabled {
return nil, ErrReadOnly
}
ar, err := i.educationUserToAddRequest(user)
if err != nil {
return nil, err
}
if err = i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error adding user")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
return nil, err
}
// Read back user from LDAP to get the generated UUID
e, err := i.getEducationUserByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createEducationUserModelFromLDAP(e), nil
}
// DeleteEducationUser deletes a given education user, identified by username or id, from the backend
func (i *LDAP) DeleteEducationUser(ctx context.Context, nameOrID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteEducationUser")
if !i.writeEnabled {
return ErrReadOnly
}
// TODO, implement a proper lookup for education Users here
e, err := i.getEducationUserByNameOrID(nameOrID)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
return nil
}
// UpdateEducationUser applies changes to given education user, identified by username or id
func (i *LDAP) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationUser")
if !i.writeEnabled {
return nil, ErrReadOnly
}
e, err := i.getEducationUserByNameOrID(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
}
e, err = i.getEducationUserByDN(e.DN)
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(),
i.educationConfig.userAttributeMap.primaryRole: user.GetPrimaryRole(),
i.educationConfig.userAttributeMap.externalID: user.GetExternalId(),
}
for attribute, value := range properties {
if value != "" {
if e.GetEqualFoldAttributeValue(attribute) != value {
mr.Replace(attribute, []string{value})
updateNeeded = true
}
}
}
if user.AccountEnabled != nil {
un, err := i.updateAccountEnabledState(logger, user.GetAccountEnabled(), e, &mr)
if err != nil {
return nil, err
}
if un {
updateNeeded = true
}
}
if user.PasswordProfile != nil && user.PasswordProfile.GetPassword() != "" {
if i.usePwModifyExOp {
if err := i.updateUserPassword(ctx, e.DN, user.PasswordProfile.GetPassword()); err != nil {
return nil, err
}
} 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
}
if updateNeeded {
if err := i.conn.Modify(&mr); err != nil {
return nil, err
}
}
// Read back user from LDAP to get the generated UUID
e, err = i.getEducationUserByDN(e.DN)
if err != nil {
return nil, err
}
returnUser := i.createEducationUserModelFromLDAP(e)
// 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
}
// GetEducationUser implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationUser(ctx context.Context, nameOrID string) (*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationUser")
e, err := i.getEducationUserByNameOrID(nameOrID)
if err != nil {
return nil, err
}
u := i.createEducationUserModelFromLDAP(e)
if u == nil {
return nil, ErrNotFound
}
return u, nil
}
// GetEducationUsers implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationUsers(ctx context.Context) ([]*libregraph.EducationUser, error) {
var filter string
if i.userFilter == "" {
filter = fmt.Sprintf("(objectClass=%s)", i.educationConfig.userObjectClass)
} else {
filter = fmt.Sprintf("(&%s(objectClass=%s))", i.userFilter, i.educationConfig.userObjectClass)
}
return i.searchEducationUsers(ctx, filter)
}
func (i *LDAP) FilterEducationUsersByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx).With().Str("func", "FilterEducationUsersByAttribute").Logger()
logger.Debug().Str("backend", "ldap").Str("attribute", attr).Str("value", value).Msg("")
var ldapAttr string
switch attr {
case "displayname":
ldapAttr = i.userAttributeMap.displayName
case "mail":
ldapAttr = i.userAttributeMap.mail
case "userType":
ldapAttr = i.userAttributeMap.userType
case "primaryRole":
ldapAttr = i.educationConfig.userAttributeMap.primaryRole
case "externalId":
ldapAttr = i.educationConfig.userAttributeMap.externalID
default:
return nil, errorcode.New(errorcode.InvalidRequest, fmt.Sprintf("filtering by attribute '%s' is not supported", attr))
}
filter := fmt.Sprintf("(&%s(objectClass=%s)(%s=%s))", i.userFilter, i.educationConfig.userObjectClass, ldap.EscapeFilter(ldapAttr), ldap.EscapeFilter(value))
return i.searchEducationUsers(ctx, filter)
}
// searchEducationUsers builds and executes an LDAP search for education users and converts the results to EducationUser models.
func (i *LDAP) searchEducationUsers(ctx context.Context, filter string) ([]*libregraph.EducationUser, error) {
searchRequest := ldap.NewSearchRequest(
i.userBaseDN,
i.userScope,
ldap.NeverDerefAliases, 0, 0, false,
filter,
i.getEducationUserAttrTypes(),
nil,
)
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("searchEducationUsers")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
users := make([]*libregraph.EducationUser, 0, len(res.Entries))
for _, e := range res.Entries {
u := i.createEducationUserModelFromLDAP(e)
// Skip invalid LDAP users
if u == nil {
continue
}
users = append(users, u)
}
return users, nil
}
func (i *LDAP) educationUserToUser(eduUser libregraph.EducationUser) *libregraph.User {
user := libregraph.NewUser(*eduUser.DisplayName, *eduUser.OnPremisesSamAccountName)
user.Surname = eduUser.Surname
user.AccountEnabled = eduUser.AccountEnabled
user.GivenName = eduUser.GivenName
user.Mail = eduUser.Mail
user.UserType = eduUser.UserType
user.Identities = eduUser.Identities
return user
}
func (i *LDAP) userToEducationUser(user libregraph.User, e *ldap.Entry) *libregraph.EducationUser {
eduUser := libregraph.NewEducationUser()
eduUser.Id = user.Id
eduUser.OnPremisesSamAccountName = &user.OnPremisesSamAccountName
eduUser.Surname = user.Surname
eduUser.AccountEnabled = user.AccountEnabled
eduUser.GivenName = user.GivenName
eduUser.DisplayName = &user.DisplayName
eduUser.Mail = user.Mail
eduUser.UserType = user.UserType
if e != nil {
// Set the education User specific Attributes from the supplied LDAP Entry
if primaryRole := e.GetEqualFoldAttributeValue(i.educationConfig.userAttributeMap.primaryRole); primaryRole != "" {
eduUser.SetPrimaryRole(primaryRole)
}
if externalID := e.GetEqualFoldAttributeValue(i.educationConfig.userAttributeMap.externalID); externalID != "" {
eduUser.SetExternalId(externalID)
}
}
return eduUser
}
func (i *LDAP) educationUserToLDAPAttrValues(user libregraph.EducationUser, attrs ldapAttributeValues) (ldapAttributeValues, error) {
if role, ok := user.GetPrimaryRoleOk(); ok {
attrs[i.educationConfig.userAttributeMap.primaryRole] = []string{*role}
}
if externalID, ok := user.GetExternalIdOk(); ok {
attrs[i.educationConfig.userAttributeMap.externalID] = []string{*externalID}
}
attrs["objectClass"] = append(attrs["objectClass"], i.educationConfig.userObjectClass)
return attrs, nil
}
func (i *LDAP) educationUserToAddRequest(user libregraph.EducationUser) (*ldap.AddRequest, error) {
plainUser := i.educationUserToUser(user)
ldapAttrs, err := i.userToLDAPAttrValues(*plainUser)
if err != nil {
return nil, err
}
ldapAttrs, err = i.educationUserToLDAPAttrValues(user, ldapAttrs)
if err != nil {
return nil, err
}
ar := ldap.NewAddRequest(i.getUserLDAPDN(*plainUser), nil)
for attrType, values := range ldapAttrs {
ar.Attribute(attrType, values)
}
return ar, nil
}
func (i *LDAP) createEducationUserModelFromLDAP(e *ldap.Entry) *libregraph.EducationUser {
user := i.createUserModelFromLDAP(e)
return i.userToEducationUser(*user, e)
}
func (i *LDAP) getEducationUserAttrTypes() []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.educationConfig.userAttributeMap.primaryRole,
i.educationConfig.userAttributeMap.externalID,
i.educationConfig.memberOfSchoolAttribute,
}
}
func (i *LDAP) getEducationUserByDN(dn string) (*ldap.Entry, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.userObjectClass)
if i.userFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.userFilter)
}
return i.getEntryByDN(dn, i.getEducationUserAttrTypes(), filter)
}
func (i *LDAP) getEducationUserByNameOrID(nameOrID string) (*ldap.Entry, error) {
return i.getEducationObjectByNameOrID(
nameOrID,
i.userAttributeMap.userName,
i.userAttributeMap.id,
i.userFilter,
i.educationConfig.userObjectClass,
i.userBaseDN,
i.getEducationUserAttrTypes(),
)
}
func (i *LDAP) getEducationObjectByNameOrID(nameOrID, nameAttribute, idAttribute, objectFilter, objectClass, baseDN string, attributes []string) (*ldap.Entry, error) {
nameOrID = ldap.EscapeFilter(nameOrID)
filter := fmt.Sprintf("(|(%s=%s)(%s=%s))", nameAttribute, nameOrID, idAttribute, nameOrID)
return i.getEducationObjectByFilter(filter, baseDN, objectFilter, objectClass, attributes)
}
func (i *LDAP) getEducationObjectByFilter(filter, baseDN, objectFilter, objectClass string, attributes []string) (*ldap.Entry, error) {
filter = fmt.Sprintf("(&%s(objectClass=%s)%s)", objectFilter, objectClass, filter)
return i.searchLDAPEntryByFilter(baseDN, attributes, filter)
}
@@ -0,0 +1,303 @@
package identity
import (
"context"
"testing"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const peopleBaseDN = "ou=people,dc=test"
var eduUserAttrs = []string{
"displayname",
"entryUUID",
"mail",
"uid",
"sn",
"givenname",
"userEnabledAttribute",
"userTypeAttribute",
"qsferaExternalIdentity",
"userClass",
"qsferaEducationExternalId",
"qsferaMemberOfSchool",
}
var eduUserEntry = ldap.NewEntry("uid=user,ou=people,dc=test",
map[string][]string{
"uid": {"testuser"},
"displayname": {"Test User"},
"mail": {"user@example"},
"entryuuid": {"abcd-defg"},
"userClass": {"student"},
"qsferaExternalIdentity": {
"$ http://idp $ testuser",
"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
},
"userTypeAttribute": {"Member"},
"userEnabledAttribute": {"FALSE"},
})
var renamedEduUserEntry = ldap.NewEntry("uid=newtestuser,ou=people,dc=test",
map[string][]string{
"uid": {"newtestuser"},
"displayname": {"Test User"},
"mail": {"user@example"},
"entryuuid": {"abcd-defg"},
"userClass": {"student"},
"qsferaExternalIdentity": {
"$ http://idp $ testuser",
"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
},
"userTypeAttribute": {"Guest"},
"userEnabledAttribute": {"TRUE"},
})
var eduUserEntryWithSchool = ldap.NewEntry("uid=user,ou=people,dc=test",
map[string][]string{
"uid": {"testuser"},
"displayname": {"Test User"},
"mail": {"user@example"},
"entryuuid": {"abcd-defg"},
"userClass": {"student"},
"qsferaMemberOfSchool": {"abcd-defg"},
"qsferaExternalIdentity": {
"$ http://idp $ testuser",
"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
},
})
var sr1 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=abcd-defg)(entryUUID=abcd-defg)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
var sr2 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=xxxx-xxxx)(entryUUID=xxxx-xxxx)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
func TestCreateEducationUser(t *testing.T) {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
//assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
lm.On("Add", mock.Anything).Return(nil)
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
eduUserEntry,
},
},
nil)
user := libregraph.NewEducationUser()
user.SetDisplayName("Test User")
user.SetOnPremisesSamAccountName("testuser")
user.SetMail("testuser@example.org")
user.SetPrimaryRole("student")
user.SetUserType(("Member"))
user.SetAccountEnabled(false)
eduUser, err := b.CreateEducationUser(context.Background(), *user)
lm.AssertNumberOfCalls(t, "Add", 1)
lm.AssertNumberOfCalls(t, "Search", 1)
assert.NotNil(t, eduUser)
assert.Nil(t, err)
assert.Equal(t, eduUser.GetDisplayName(), user.GetDisplayName())
assert.Equal(t, eduUser.GetOnPremisesSamAccountName(), user.GetOnPremisesSamAccountName())
assert.Equal(t, "abcd-defg", eduUser.GetId())
assert.Equal(t, eduUser.GetPrimaryRole(), user.GetPrimaryRole())
assert.Equal(t, eduUser.GetUserType(), user.GetUserType())
assert.Equal(t, eduUser.GetAccountEnabled(), false)
}
func TestDeleteEducationUser(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
dr1 := &ldap.DelRequest{
DN: "uid=user,ou=people,dc=test",
}
lm.On("Del", dr1).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.DeleteEducationUser(context.Background(), "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 1)
lm.AssertNumberOfCalls(t, "Del", 1)
assert.Nil(t, err)
err = b.DeleteEducationUser(context.Background(), "xxxx-xxxx")
lm.AssertNumberOfCalls(t, "Search", 2)
lm.AssertNumberOfCalls(t, "Del", 1)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
}
func TestGetEducationUser(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
user, err := b.GetEducationUser(context.Background(), "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
assert.Equal(t, "Test User", user.GetDisplayName())
assert.Equal(t, "abcd-defg", user.GetId())
_, err = b.GetEducationUser(context.Background(), "xxxx-xxxx")
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
}
func TestGetEducationUsers(t *testing.T) {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 0,
Filter: "(objectClass=qsferaEducationUser)",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
_, err = b.GetEducationUsers(context.Background())
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
}
func TestFilterEducationUsersByAttr(t *testing.T) {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 0,
Filter: "(&(objectClass=qsferaEducationUser)(qsferaEducationExternalId=id1234))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
_, err = b.FilterEducationUsersByAttribute(context.Background(), "externalId", "id1234")
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
}
func TestUpdateEducationUser(t *testing.T) {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
userSearchReq := &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=testuser)(entryUUID=testuser)))",
Attributes: eduUserAttrs,
}
userLookupReq := &ldap.SearchRequest{
BaseDN: "uid=newtestuser,ou=people,dc=test",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
}
eduUserLookupReq := &ldap.SearchRequest{
BaseDN: "uid=newtestuser,ou=people,dc=test",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=qsferaEducationUser)",
Attributes: eduUserAttrs,
}
groupSearchReq := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
Filter: "(&(objectClass=groupOfNames)(member=uid=user,ou=people,dc=test))",
Attributes: []string{
"cn",
"entryUUID",
},
}
lm.On("Search", userLookupReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
renamedEduUserEntry,
},
},
nil)
lm.On("Search", eduUserLookupReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
renamedEduUserEntry,
},
},
nil)
lm.On("Search", userSearchReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
eduUserEntry,
},
},
nil)
lm.On("Search", groupSearchReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{},
},
nil)
modReq := ldap.ModifyRequest{
DN: "uid=newtestuser,ou=people,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "mail",
Vals: []string{"new@mail.org"},
},
},
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "userEnabledAttribute",
Vals: []string{"TRUE"},
},
},
},
}
modDNReq := ldap.ModifyDNRequest{
DN: "uid=user,ou=people,dc=test",
NewRDN: "uid=newtestuser",
DeleteOldRDN: true,
}
lm.On("ModifyDN", &modDNReq).Return(nil)
lm.On("Modify", &modReq).Return(nil)
user := libregraph.NewEducationUser()
user.SetOnPremisesSamAccountName("newtestuser")
user.SetMail("new@mail.org")
user.SetAccountEnabled(true)
eduUser, err := b.UpdateEducationUser(context.Background(), "testuser", *user)
assert.NotNil(t, eduUser)
assert.Nil(t, err)
assert.Equal(t, eduUser.GetOnPremisesSamAccountName(), "newtestuser")
assert.Equal(t, "abcd-defg", eduUser.GetId())
assert.Equal(t, "Guest", eduUser.GetUserType())
assert.Equal(t, eduUser.GetAccountEnabled(), true)
}
@@ -0,0 +1,607 @@
package identity
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"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/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/odata"
)
type groupAttributeMap struct {
name string
id string
member string
}
// GetGroup implements the Backend Interface for the LDAP Backend
func (i *LDAP) GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetGroup")
e, err := i.getLDAPGroupByNameOrID(nameOrID, true)
if err != nil {
return nil, err
}
sel := strings.Split(queryParam.Get("$select"), ",")
exp := strings.Split(queryParam.Get("$expand"), ",")
var g *libregraph.Group
if g = i.createGroupModelFromLDAP(e); g == nil {
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
}
if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
members, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, "")
if err != nil {
return nil, err
}
g.Members = make([]libregraph.User, 0, len(members))
if len(members) > 0 {
for _, ue := range members {
if u := i.createUserModelFromLDAP(ue); u != nil {
g.Members = append(g.Members, *u)
}
}
}
}
return g, nil
}
// GetGroups implements the Backend Interface for the LDAP Backend
func (i *LDAP) GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetGroups")
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
var expandMembers bool
exp, err := odata.GetExpandValues(oreq.Query)
if err != nil {
return nil, err
}
sel, err := odata.GetSelectValues(oreq.Query)
if err != nil {
return nil, err
}
if slices.Contains(exp, "members") || slices.Contains(sel, "members") {
expandMembers = true
}
var groupFilter string
if search != "" {
search = ldap.EscapeFilter(search)
groupFilter = fmt.Sprintf(
"(%s=*%s*)",
i.groupAttributeMap.name, search,
)
}
groupFilter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, groupFilter)
groupAttrs := []string{
i.groupAttributeMap.name,
i.groupAttributeMap.id,
}
if expandMembers {
groupAttrs = append(groupAttrs, i.groupAttributeMap.member)
}
searchRequest := ldap.NewSearchRequest(
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
groupFilter,
groupAttrs,
nil,
)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetGroups")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
groups := make([]*libregraph.Group, 0, len(res.Entries))
var g *libregraph.Group
for _, e := range res.Entries {
if g = i.createGroupModelFromLDAP(e); g == nil {
continue
}
if expandMembers {
members, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, "")
if err != nil {
return nil, err
}
g.Members = make([]libregraph.User, 0, len(members))
if len(members) > 0 {
for _, ue := range members {
if u := i.createUserModelFromLDAP(ue); u != nil {
g.Members = append(g.Members, *u)
}
}
}
}
groups = append(groups, g)
}
return groups, nil
}
// GetGroupMembers implements the Backend Interface for the LDAP Backend
func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string, req *godata.GoDataRequest) ([]*libregraph.User, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetGroupMembers")
exp, err := odata.GetExpandValues(req.Query)
if err != nil {
return nil, err
}
e, err := i.getLDAPGroupByNameOrID(groupID, true)
if err != nil {
return nil, err
}
searchTerm, err := odata.GetSearchValues(req.Query)
if err != nil {
return nil, err
}
memberEntries, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, searchTerm)
result := make([]*libregraph.User, 0, len(memberEntries))
if err != nil {
return nil, err
}
for _, member := range memberEntries {
if u := i.createUserModelFromLDAP(member); u != nil {
if slices.Contains(exp, "memberOf") {
userGroups, err := i.getGroupsForUser(member.DN)
if err != nil {
return nil, err
}
u.MemberOf = i.groupsFromLDAPEntries(userGroups)
}
result = append(result, u)
}
}
return result, nil
}
// CreateGroup implements the Backend Interface for the LDAP Backend
// It is currently restricted to managing groups based on the "groupOfNames" ObjectClass.
// As "groupOfNames" requires a "member" Attribute to be present. Empty Groups (groups
// without a member) a represented by adding an empty DN as the single member.
func (i *LDAP) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("create group")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ar, err := i.groupToAddRequest(group)
if err != nil {
return nil, err
}
if err := i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Str("backend", "ldap").Str("dn", group.GetDisplayName()).Err(err).Msg("Failed to create group")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, "group already exists")
}
}
return nil, err
}
// Read back group from LDAP to get the generated UUID
e, err := i.getGroupByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createGroupModelFromLDAP(e), nil
}
// DeleteGroup implements the Backend Interface.
func (i *LDAP) DeleteGroup(ctx context.Context, id string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
e, err := i.getLDAPGroupByID(id, false)
if err != nil {
return err
}
if i.isLDAPGroupReadOnly(e) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
return nil
}
// UpdateGroupName implements the Backend Interface.
func (i *LDAP) UpdateGroupName(ctx context.Context, groupID string, groupName string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ge, err := i.getLDAPGroupByID(groupID, true)
if err != nil {
return err
}
if i.isLDAPGroupReadOnly(ge) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
if ge.GetEqualFoldAttributeValue(i.groupAttributeMap.name) == groupName {
return nil
}
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.groupAttributeMap.name,
Value: groupName,
}
newDNString := attributeTypeAndValue.String()
logger.Debug().Str("originalDN", ge.DN).Str("newDN", newDNString).Msg("Modifying DN")
mrdn := ldap.NewModifyDNRequest(ge.DN, newDNString, true, "")
if err := i.conn.ModifyDN(mrdn); err != nil {
var lerr *ldap.Error
logger.Debug().Str("originalDN", ge.DN).Str("newDN", newDNString).Err(err).Msg("Failed to modify DN")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, "Group name already in use")
}
}
return err
}
return nil
}
// AddMembersToGroup implements the Backend Interface for the LDAP backend.
// Currently, it is limited to adding Users as Group members. Adding other groups
// as members is not yet implemented
func (i *LDAP) AddMembersToGroup(ctx context.Context, groupID string, memberIDs []string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ge, err := i.getLDAPGroupByNameOrID(groupID, true)
if err != nil {
return err
}
if i.isLDAPGroupReadOnly(ge) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
mr := ldap.ModifyRequest{DN: ge.DN}
// Handle empty groups (using the empty member attribute)
current := ge.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
if len(current) == 1 && current[0] == "" {
mr.Delete(i.groupAttributeMap.member, []string{""})
}
// Create a Set of current members for faster lookups
currentSet := make(map[string]struct{}, len(current))
for _, currentMember := range current {
// We can ignore any empty member value here
if currentMember == "" {
continue
}
nCurrentMember, err := ldapdn.ParseNormalize(currentMember)
if err != nil {
// We couldn't parse the member value as a DN. Let's skip it, but log a warning
logger.Warn().Str("memberDN", currentMember).Err(err).Msg("Couldn't parse DN")
continue
}
currentSet[nCurrentMember] = struct{}{}
}
var newMemberDN []string
for _, memberID := range memberIDs {
me, err := i.getLDAPUserByID(memberID)
if err != nil {
return err
}
nDN, err := ldapdn.ParseNormalize(me.DN)
if err != nil {
logger.Error().Str("new member", me.DN).Err(err).Msg("Couldn't parse DN")
return err
}
if _, present := currentSet[nDN]; !present {
newMemberDN = append(newMemberDN, me.DN)
} else {
logger.Debug().Str("memberDN", me.DN).Msg("Member already present in group. Skipping")
}
}
if len(newMemberDN) > 0 {
// Small retry loop. It might be that, when reading the group we found the empty group member ("",
// line 289 above). Our modify operation tries to delete that value. However, another go-routine
// might have done that in parallel. In that case
// (LDAPResultNoSuchAttribute) we need to retry the modification
// without to delete.
for j := 0; j < 2; j++ {
mr.Add(i.groupAttributeMap.member, newMemberDN)
if err := i.conn.Modify(&mr); err != nil {
if lerr, ok := err.(*ldap.Error); ok {
switch lerr.ResultCode {
case ldap.LDAPResultAttributeOrValueExists:
err = fmt.Errorf("duplicate member entries in request")
case ldap.LDAPResultNoSuchAttribute:
if len(mr.Changes) == 2 {
// We tried the special case for adding the first group member, but some
// other request running in parallel did that already. Retry with a "normal"
// modification
logger.Debug().Err(err).
Msg("Failed to add first group member. Retrying once, without deleting the empty member value.")
mr.Changes = make([]ldap.Change, 0, 1)
continue
}
default:
logger.Info().Err(err).Msg("Failed to modify group member entries on PATCH group")
err = fmt.Errorf("unknown error when trying to modify group member entries")
}
}
return err
}
// succeeded
break
}
}
return nil
}
// RemoveMemberFromGroup implements the Backend Interface.
func (i *LDAP) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("RemoveMemberFromGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ge, err := i.getLDAPGroupByID(groupID, true)
if err != nil {
logger.Debug().Str("backend", "ldap").Str("groupID", groupID).Msg("Error looking up group")
return err
}
if i.isLDAPGroupReadOnly(ge) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
me, err := i.getLDAPUserByID(memberID)
if err != nil {
logger.Debug().Str("backend", "ldap").Str("memberID", memberID).Msg("Error looking up group member")
return err
}
logger.Debug().Str("backend", "ldap").Str("groupdn", ge.DN).Str("member", me.DN).Msg("remove member")
if err = i.removeEntryByDNAndAttributeFromEntry(ge, me.DN, i.groupAttributeMap.member); err != nil {
logger.Error().Err(err).Str("backend", "ldap").Str("group", groupID).Str("member", memberID).Msg("Failed to remove member from group.")
}
return err
}
func (i *LDAP) groupToAddRequest(group libregraph.Group) (*ldap.AddRequest, error) {
ar := ldap.NewAddRequest(i.getGroupCreateLDAPDN(group), nil)
attrMap, err := i.groupToLDAPAttrValues(group)
if err != nil {
return nil, err
}
for attrType, values := range attrMap {
ar.Attribute(attrType, values)
}
return ar, nil
}
func (i *LDAP) getGroupCreateLDAPDN(group libregraph.Group) string {
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: "cn",
Value: group.GetDisplayName(),
}
return fmt.Sprintf("%s,%s", attributeTypeAndValue.String(), i.groupCreateBaseDN)
}
func (i *LDAP) groupToLDAPAttrValues(group libregraph.Group) (map[string][]string, error) {
attrs := map[string][]string{
i.groupAttributeMap.name: {group.GetDisplayName()},
"objectClass": {"groupOfNames", "top"},
// This is a crutch to allow groups without members for LDAP servers
// that apply strict Schema checking. The RFCs define "member/uniqueMember"
// as required attribute for groupOfNames/groupOfUniqueNames. So we
// add an empty string (which is a valid DN) as the initial member.
// It will be replaced once real members are added.
// We might want to use the newer, but not so broadly used "groupOfMembers"
// objectclass (RFC2307bis-02) where "member" is optional.
i.groupAttributeMap.member: {""},
}
if !i.useServerUUID {
attrs["qsferaUUID"] = []string{uuid.NewString()}
attrs["objectClass"] = append(attrs["objectClass"], "qsferaObject")
}
return attrs, nil
}
func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, error) {
idString, err := filterEscapeAttribute(i.groupAttributeMap.id, i.groupIDisOctetString, id)
if err != nil {
return nil, fmt.Errorf("invalid group id: %w", err)
}
filter := fmt.Sprintf("(%s=%s)", i.groupAttributeMap.id, idString)
return i.getLDAPGroupByFilter(filter, requestMembers)
}
func (i *LDAP) getLDAPGroupByNameOrID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
idString, err := filterEscapeAttribute(i.groupAttributeMap.id, i.groupIDisOctetString, nameOrID)
// err != nil just means that this is not an uuid, so we can skip the uuid filter part
// and just filter by name
filter := ""
if err == nil {
filter = fmt.Sprintf("(|(%s=%s)(%s=%s))", i.groupAttributeMap.name, ldap.EscapeFilter(nameOrID), i.groupAttributeMap.id, idString)
} else {
filter = fmt.Sprintf("(%s=%s)", i.userAttributeMap.userName, ldap.EscapeFilter(nameOrID))
}
return i.getLDAPGroupByFilter(filter, requestMembers)
}
func (i *LDAP) getLDAPGroupByFilter(filter string, requestMembers bool) (*ldap.Entry, error) {
e, err := i.getLDAPGroupsByFilter(filter, requestMembers, true)
if err != nil {
return nil, err
}
if len(e) == 0 {
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
}
return e[0], nil
}
// Search for LDAP Groups matching the specified filter, if requestMembers is true the groupMemberShip
// attribute will be part of the result attributes. The LDAP filter is combined with the configured groupFilter
// resulting in a filter like "(&(LDAP.groupFilter)(objectClass=LDAP.groupObjectClass)(<filter_from_args>))"
func (i *LDAP) getLDAPGroupsByFilter(filter string, requestMembers, single bool) ([]*ldap.Entry, error) {
attrs := []string{
i.groupAttributeMap.name,
i.groupAttributeMap.id,
}
if requestMembers {
attrs = append(attrs, i.groupAttributeMap.member)
}
sizelimit := 0
if single {
sizelimit = 1
}
searchRequest := ldap.NewSearchRequest(
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, sizelimit, 0, false,
fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, filter),
attrs,
nil,
)
i.logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("getLDAPGroupsByFilter")
res, err := i.conn.Search(searchRequest)
if err != nil {
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for group '%s'", filter)
i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
}
}
return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
}
return res.Entries, nil
}
func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
attrs := []string{
i.groupAttributeMap.id,
i.groupAttributeMap.name,
}
filter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
if i.groupFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
}
return i.getEntryByDN(dn, attrs, filter)
}
func (i *LDAP) getGroupsForUser(dn string) ([]*ldap.Entry, error) {
groupFilter := fmt.Sprintf(
"(%s=%s)",
i.groupAttributeMap.member, ldap.EscapeFilter(dn),
)
userGroups, err := i.getLDAPGroupsByFilter(groupFilter, false, false)
if err != nil {
return nil, err
}
return userGroups, nil
}
func (i *LDAP) createGroupModelFromLDAP(e *ldap.Entry) *libregraph.Group {
name := e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)
id, err := i.ldapUUIDtoString(e, i.groupAttributeMap.id, i.groupIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", e.DN).Str(i.groupAttributeMap.id, e.GetEqualFoldAttributeValue(i.groupAttributeMap.id)).Msg("Invalid User. Cannot convert UUID")
}
groupTypes := []string{}
if i.isLDAPGroupReadOnly(e) {
groupTypes = []string{"ReadOnly"}
}
if id != "" && name != "" {
return &libregraph.Group{
DisplayName: &name,
Id: &id,
GroupTypes: groupTypes,
}
}
i.logger.Warn().Str("dn", e.DN).Msg("Group is missing name or id")
return nil
}
func (i *LDAP) isLDAPGroupReadOnly(e *ldap.Entry) bool {
groupDN, err := ldap.ParseDN(e.DN)
if err != nil {
i.logger.Warn().Err(err).Str("dn", e.DN).Msg("Failed to parse DN")
return false
}
baseDN, err := ldap.ParseDN(i.groupCreateBaseDN)
if err != nil {
i.logger.Warn().Err(err).Str("dn", i.groupCreateBaseDN).Msg("Failed to parse DN")
return false
}
return !baseDN.AncestorOfFold(groupDN)
}
func (i *LDAP) groupsFromLDAPEntries(e []*ldap.Entry) []libregraph.Group {
groups := make([]libregraph.Group, 0, len(e))
for _, g := range e {
if grp := i.createGroupModelFromLDAP(g); grp != nil {
groups = append(groups, *grp)
}
}
return groups
}
@@ -0,0 +1,453 @@
package identity
import (
"context"
"errors"
"net/url"
"testing"
"github.com/CiscoM31/godata"
"github.com/go-ldap/ldap/v3"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var groupEntry = ldap.NewEntry("cn=group",
map[string][]string{
"cn": {"group"},
"entryuuid": {"abcd-defg"},
"member": {
"uid=user,ou=people,dc=test",
"uid=invalid,ou=people,dc=test",
},
})
var invalidGroupEntry = ldap.NewEntry("cn=invalid",
map[string][]string{
"cn": {"invalid"},
})
var queryParamExpand = url.Values{
"$expand": []string{"members"},
}
var queryParamSelect = url.Values{
"$select": []string{"members"},
}
var groupLookupSearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=groupOfNames)(|(cn=group)(entryUUID=group)))",
Attributes: []string{"cn", "entryUUID", "member"},
Controls: []ldap.Control(nil),
}
var groupListSearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
Filter: "(&(objectClass=groupOfNames))",
Attributes: []string{"cn", "entryUUID", "member"},
Controls: []ldap.Control(nil),
}
func TestGetGroup(t *testing.T) {
// Mock a Sizelimit Error
lm := &mocks.Client{}
lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock")))
b, _ := getMockedBackend(lm, lconfig, &logger)
_, err := b.GetGroup(context.Background(), "group", nil)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
assert.ErrorContains(t, err, "itemNotFound:")
// Mock an empty Search Result
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
_, err = b.GetGroup(context.Background(), "group", nil)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
assert.ErrorContains(t, err, "itemNotFound:")
// Mock an invalid Search Result
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{invalidGroupEntry},
}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
_, err = b.GetGroup(context.Background(), "group", nil)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
assert.ErrorContains(t, err, "itemNotFound:")
// Mock a valid Search Result
lm = &mocks.Client{}
sr2 := &ldap.SearchRequest{
BaseDN: "uid=user,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
sr3 := &ldap.SearchRequest{
BaseDN: "uid=invalid,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err := b.GetGroup(context.Background(), "group", nil)
if err != nil {
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
} else if *g.Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
t.Errorf("Expected GetGroup to return a valid group")
}
g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
case len(g.Members) != 1:
t.Errorf("Expected GetGroup with expand to return one member")
case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
t.Errorf("Expected GetGroup with expand to return correct member")
}
g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
case len(g.Members) != 1:
t.Errorf("Expected GetGroup with expand to return one member")
case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
t.Errorf("Expected GetGroup with expand to return correct member")
}
}
func TestGetGroupReadOnlyBackend(t *testing.T) {
readOnlyConfig := lconfig
readOnlyConfig.WriteEnabled = false
readOnlyConfig.GroupBaseDN = "ou=groups,dc=test"
readOnlyConfig.GroupCreateBaseDN = "ou=local,ou=group,dc=test"
localGroupEntry := groupEntry
localGroupEntry.DN = "cn=local,ou=local,o=base"
lm := &mocks.Client{}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
b, _ := getMockedBackend(lm, readOnlyConfig, &logger)
g, err := b.GetGroup(context.Background(), "group", url.Values{})
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
}
types := g.GetGroupTypes()
switch {
case len(types) == 0:
t.Errorf("No groupTypes attribute on readonly Group")
case len(types) > 1:
t.Errorf("Expected a single groupTypes value on readonly Group")
case types[0] != "ReadOnly":
t.Errorf("Expected a groupTypes 'ReadOnly' on readonly Group")
}
}
func TestGetGroupReadOnlySubtree(t *testing.T) {
readOnlyTreeConfig := lconfig
readOnlyTreeConfig.GroupCreateBaseDN = "ou=write,ou=groups,dc=test"
var writeGroupEntry = ldap.NewEntry("cn=group,ou=write,ou=groups,dc=test",
map[string][]string{
"cn": {"group"},
"entryuuid": {"abcd-defg"},
"member": {
"uid=user,ou=people,dc=test",
"uid=invalid,ou=people,dc=test",
},
})
lm := &mocks.Client{}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
b, _ := getMockedBackend(lm, readOnlyTreeConfig, &logger)
g, err := b.GetGroup(context.Background(), "group", url.Values{})
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
}
types := g.GetGroupTypes()
switch {
case len(types) == 0:
t.Errorf("No groupTypes attribute on readonly Group")
case len(types) > 1:
t.Errorf("Expected a single groupTypes value on readonly Group")
case types[0] != "ReadOnly":
t.Errorf("Expected a groupTypes 'ReadOnly' on readonly Group")
}
lm = &mocks.Client{}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{writeGroupEntry}}, nil)
b, _ = getMockedBackend(lm, readOnlyTreeConfig, &logger)
g, err = b.GetGroup(context.Background(), "group", url.Values{})
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
}
types = g.GetGroupTypes()
if len(types) != 0 {
t.Errorf("No groupTypes attribute expected on writeable Group")
}
}
func TestGetGroups(t *testing.T) {
lm := &mocks.Client{}
oDataReq, err := godata.ParseRequest(context.Background(), "", url.Values{})
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
}
lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
b, _ := getMockedBackend(lm, lconfig, &logger)
_, err = b.GetGroups(context.Background(), oDataReq)
assert.ErrorContains(t, err, "itemNotFound:")
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err := b.GetGroups(context.Background(), oDataReq)
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
} else if g == nil || len(g) != 0 {
t.Errorf("Expected zero length user slice")
}
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{groupEntry},
}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err = b.GetGroups(context.Background(), oDataReq)
if err != nil {
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
} else if *g[0].Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
t.Errorf("Expected GetGroup to return a valid group")
}
// Mock a valid Search Result with expanded group members
lm = &mocks.Client{}
sr2 := &ldap.SearchRequest{
BaseDN: "uid=user,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
sr3 := &ldap.SearchRequest{
BaseDN: "uid=invalid,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
for _, param := range []url.Values{queryParamSelect, queryParamExpand} {
oDataReq, err := godata.ParseRequest(context.Background(), "", param)
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
}
lm.On("Search", groupListSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err = b.GetGroups(context.Background(), oDataReq)
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g[0].GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
case len(g[0].Members) != 1:
t.Errorf("Expected GetGroup to return group with one member")
case g[0].Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
t.Errorf("Expected GetGroup to return group with correct member")
}
}
}
func TestGetGroupsSearch(t *testing.T) {
lm := &mocks.Client{}
odataReqDefault, err := godata.ParseRequest(context.Background(), "",
url.Values{
"$search": []string{"\"term\""},
},
)
if err != nil {
t.Errorf("Expected success got '%s'", err.Error())
}
// only match if the filter contains the search term unquoted
lm.On("Search", mock.MatchedBy(
func(req *ldap.SearchRequest) bool {
return req.Filter == "(&(objectClass=groupOfNames)(cn=*term*))"
})).
Return(&ldap.SearchResult{}, nil)
b, _ := getMockedBackend(lm, lconfig, &logger)
g, err := b.GetGroups(context.Background(), odataReqDefault)
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
} else if g == nil || len(g) != 0 {
t.Errorf("Expected zero length user slice")
}
}
func TestUpdateGroupName(t *testing.T) {
groupDn := "cn=TheGroup,ou=groups,dc=example,dc=org"
type args struct {
groupId string
groupName string
newName string
}
type mockInputs struct {
funcName string
args []any
returns []any
}
tests := []struct {
name string
args args
assertion assert.ErrorAssertionFunc
ldapMocks []mockInputs
}{
{
name: "Test with no name change",
args: args{
groupId: "some-uuid-string",
newName: "TheGroup",
},
assertion: func(t assert.TestingT, err error, args ...any) bool {
return assert.Nil(t, err, args...)
},
ldapMocks: []mockInputs{
{
funcName: "Search",
args: []any{
ldap.NewSearchRequest(
"ou=groups,dc=test",
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 1, 0, false,
"(&(objectClass=groupOfNames)(entryUUID=some-uuid-string))",
[]string{"cn", "entryUUID", "member"},
nil,
),
},
returns: []any{
&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: groupDn,
Attributes: []*ldap.EntryAttribute{
{
Name: "cn",
Values: []string{"TheGroup"},
},
},
},
},
},
nil,
},
},
},
},
{
name: "Test with name change",
args: args{
groupId: "some-uuid-string",
newName: "TheGroupWithShinyNewName",
},
assertion: func(t assert.TestingT, err error, args ...any) bool {
return assert.Nil(t, err, args...)
},
ldapMocks: []mockInputs{
{
funcName: "Search",
args: []any{
ldap.NewSearchRequest(
"ou=groups,dc=test",
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 1, 0, false,
"(&(objectClass=groupOfNames)(entryUUID=some-uuid-string))",
[]string{"cn", "entryUUID", "member"},
nil,
),
},
returns: []any{
&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "cn=TheGroup,ou=groups,dc=example,dc=org",
Attributes: []*ldap.EntryAttribute{
{
Name: "cn",
Values: []string{"TheGroup"},
},
},
},
},
},
nil,
},
},
{
funcName: "ModifyDN",
args: []any{
&ldap.ModifyDNRequest{
DN: groupDn,
NewRDN: "cn=TheGroupWithShinyNewName",
DeleteOldRDN: true,
NewSuperior: "",
Controls: []ldap.Control(nil),
},
},
returns: []any{
nil,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lm := &mocks.Client{}
for _, ldapMock := range tt.ldapMocks {
lm.On(ldapMock.funcName, ldapMock.args...).Return(ldapMock.returns...)
}
ldapConfig := lconfig
i, _ := getMockedBackend(lm, ldapConfig, &logger)
err := i.UpdateGroupName(context.Background(), tt.args.groupId, tt.args.newName)
tt.assertion(t, err)
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff