Initial QSfera import
This commit is contained in:
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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()))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user