diff --git a/README.md b/README.md index c47aa998..1ecc2e41 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ password: `admin` After you login you can create new resources to be synced by baton. After creating new resources on the LDAP server, use the `baton-ldap` cli to sync the data from the LDAP server with the example command below. -`baton-ldap --base-dn dc=example,dc=org --user-dn cn=admin,dc=example,dc=org --password admin --domain localhost` +`baton-ldap --base-dn dc=example,dc=org --bind-dn cn=admin,dc=example,dc=org --password admin --domain localhost` After successfully syncing data, use the baton CLI to list the resources and see the synced data. `baton resources` diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index d56b5923..56d2b7c4 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -87,6 +87,7 @@ func createConnector(ctx context.Context, t *testing.T, fixtureName string) (*LD BaseDN: mustParseDN(t, "dc=example,dc=org"), GroupSearchDN: mustParseDN(t, "ou=groups,dc=example,dc=org"), UserSearchDN: mustParseDN(t, "ou=users,dc=example,dc=org"), + RoleSearchDN: mustParseDN(t, "ou=roles,dc=example,dc=org"), BindPassword: "hunter2", } return New(ctx, cf) @@ -97,3 +98,13 @@ func mustParseDN(t *testing.T, input string) *ldap.DN { require.NoError(t, err) return dn } + +func pluck[T any](slice []T, fn func(v T) bool) T { + var emptyT T + for _, v := range slice { + if fn(v) { + return v + } + } + return emptyT +} diff --git a/pkg/connector/group.go b/pkg/connector/group.go index 2207586e..9e85e866 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "regexp" "sync" "github.com/conductorone/baton-ldap/pkg/ldap" @@ -21,17 +20,12 @@ import ( ) const ( - groupObjectClasses = "(objectClass=groupOfUniqueNames)(objectClass=posixGroup)(objectClass=group)" - groupFilter = "(|" + groupObjectClasses + ")" - groupIdFilter = "(&(gidNumber=%s)(|" + groupObjectClasses + "))" - groupMemberUidFilter = `(& - (|` + userObjectClasses + `) - (uid=%s) -)` - groupMemberCommonNameFilter = `(& -(|` + userObjectClasses + `) -(cn=%s) -)` + groupObjectClasses = "(objectClass=groupOfUniqueNames)(objectClass=posixGroup)(objectClass=group)" + groupFilter = "(|" + groupObjectClasses + ")" + groupIdFilter = "(&(gidNumber=%s)(|" + groupObjectClasses + "))" + + groupMemberUIDFilter = `(&` + userFilter + `(uid=%s))` + groupMemberCommonNameFilter = `(&` + userFilter + `(cn=%s))` attrGroupCommonName = "cn" attrGroupIdPosix = "gidNumber" @@ -166,8 +160,6 @@ func newGrantFromDN(resource *v2.Resource, userDN string) *v2.Grant { return g } -var numericUidRe = regexp.MustCompile(`^\d+$`) - func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, token *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { l := ctxzap.Extract(ctx) groupDN, err := ldap.CanonicalizeDN(resource.Id.Resource) @@ -193,67 +185,20 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, t // create membership grants var rv []*v2.Grant for memberId := range memberIDs.Iter() { - var memberEntry []*ldap.Entry - - if parsedDN, err := ldap.CanonicalizeDN(memberId); err == nil { + parsedDN, err := ldap.CanonicalizeDN(memberId) + if err == nil { g := newGrantFromDN(resource, parsedDN.String()) rv = append(rv, g) continue } - g.uid2dnMtx.Lock() - if dn, ok := g.uid2dnCache[memberId]; ok { - g.uid2dnMtx.Unlock() - g := newGrantFromDN(resource, dn) - rv = append(rv, g) - continue - } - g.uid2dnMtx.Unlock() - - var filter string - if numericUidRe.MatchString(memberId) { - filter = fmt.Sprintf(groupMemberUidFilter, ldap3.EscapeFilter(memberId)) - } else { - filter = fmt.Sprintf(groupMemberCommonNameFilter, ldap3.EscapeFilter(memberId)) - } - - // Group member doesn't look like it is a DN, search for it as a UID - memberEntry, _, err = g.client.LdapSearch( - ctx, - ldap3.ScopeWholeSubtree, - g.userSearchDN, - filter, - nil, - "", - 1, - ) - + memberDN, err := g.findMember(ctx, memberId) if err != nil { - // TODO: collect errors - l.Error("ldap-connector: failed to get user", zap.String("member_id", memberId), zap.Error(err)) - continue - } - if len(memberEntry) == 0 { - l.Error("ldap-connector: expanding group: failed to find user by DN or UID", zap.String("member_id", memberId), zap.String("search_filter", filter)) - continue - } - - if len(memberEntry) > 1 { - l.Error("ldap-connector: expanding group: multiple users found by DN or UID", zap.String("member_id", memberId), zap.String("search_filter", filter)) - continue + return nil, "", nil, err } - mem := memberEntry[0] - memDN, err := ldap.CanonicalizeDN(mem.DN) - if err != nil { - l.Error("ldap-connector: expanding group: invalid DN", zap.String("member_id", memberId), zap.String("search_filter", filter), zap.Error(err)) + if memberDN == "" { continue } - memberDN := memDN.String() - g.uid2dnMtx.Lock() - if len(memberEntry) == 1 { - g.uid2dnCache[memberId] = memberDN - } - g.uid2dnMtx.Unlock() g := newGrantFromDN(resource, memberDN) rv = append(rv, g) } @@ -261,6 +206,77 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, t return rv, "", nil, nil } +func (g *groupResourceType) findMember(ctx context.Context, memberId string) (string, error) { + g.uid2dnMtx.Lock() + if dn, ok := g.uid2dnCache[memberId]; ok { + g.uid2dnMtx.Unlock() + return dn, nil + } + g.uid2dnMtx.Unlock() + + filter := fmt.Sprintf(groupMemberUIDFilter, ldap3.EscapeFilter(memberId)) + dn, err := g.findMemberByFilter(ctx, memberId, filter) + if err != nil { + return "", err + } + if dn != "" { + return dn, nil + } + + filter = fmt.Sprintf(groupMemberCommonNameFilter, ldap3.EscapeFilter(memberId)) + dn, err = g.findMemberByFilter(ctx, memberId, filter) + if err != nil { + return "", err + } + if dn != "" { + return dn, nil + } + return "", nil +} + +func (g *groupResourceType) findMemberByFilter(ctx context.Context, memberId string, filter string) (string, error) { + l := ctxzap.Extract(ctx) + + memberEntry, _, err := g.client.LdapSearch( + ctx, + ldap3.ScopeWholeSubtree, + g.userSearchDN, + filter, + nil, + "", + 1, + ) + + if err != nil { + l.Error("ldap-connector: expanding group: failed to get user", zap.String("member_id", memberId), zap.Error(err)) + return "", err + } + + if len(memberEntry) == 0 { + l.Error("ldap-connector: expanding group: failed to find user by uid", zap.String("member_id", memberId), zap.String("search_filter", filter)) + return "", nil + } + + if len(memberEntry) > 1 { + err := fmt.Errorf("multiple users found by search") + l.Error("ldap-connector: expanding group: multiple users found by search", zap.String("member_id", memberId), zap.String("search_filter", filter)) + return "", err + } + + mem := memberEntry[0] + memDN, err := ldap.CanonicalizeDN(mem.DN) + if err != nil { + l.Error("ldap-connector: expanding group: invalid DN", zap.String("member_id", memberId), zap.String("search_filter", filter), zap.Error(err), zap.String("member_dn", mem.DN)) + return "", err + } + + memberDN := memDN.String() + g.uid2dnMtx.Lock() + g.uid2dnCache[memberId] = memberDN + g.uid2dnMtx.Unlock() + return memberDN, nil +} + func (g *groupResourceType) getGroup(ctx context.Context, groupDN string) (*ldap3.Entry, error) { gdn, err := ldap.CanonicalizeDN(groupDN) if err != nil { diff --git a/pkg/connector/group_test.go b/pkg/connector/group_test.go index b76b6fc8..a42a744d 100644 --- a/pkg/connector/group_test.go +++ b/pkg/connector/group_test.go @@ -6,13 +6,17 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/stretchr/testify/require" + "go.uber.org/zap" ) func TestGroupGrantRevoke(t *testing.T) { ctx, done := context.WithCancel(context.Background()) defer done() + ctx = ctxzap.ToContext(ctx, zap.Must(zap.NewDevelopment())) + connector, err := createConnector(ctx, t, "simple..ldif") require.NoError(t, err) @@ -75,13 +79,3 @@ func TestGroupGrantRevoke(t *testing.T) { require.EqualExportedValues(t, rogerGrant.Entitlement, grants[0].Entitlement) require.Equal(t, rogerGrant.Id, grants[0].Id) } - -func pluck[T any](slice []T, fn func(v T) bool) T { - var emptyT T - for _, v := range slice { - if fn(v) { - return v - } - } - return emptyT -} diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index b7d1c034..49473e70 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -8,7 +8,6 @@ import ( "github.com/conductorone/baton-sdk/pkg/pagination" mapset "github.com/deckarep/golang-set/v2" "github.com/go-ldap/ldap/v3" - "google.golang.org/protobuf/types/known/structpb" ) var ResourcesPageSize uint32 = 50 @@ -59,7 +58,7 @@ func parseValues(entry *ldap.Entry, targetAttrs []string) mapset.Set[string] { func parseValue(entry *ldap.Entry, targetAttrs []string) string { for _, targetAttr := range targetAttrs { - payload := entry.GetAttributeValue(targetAttr) + payload := entry.GetEqualFoldAttributeValue(targetAttr) if payload != "" { return payload @@ -68,36 +67,3 @@ func parseValue(entry *ldap.Entry, targetAttrs []string) string { return "" } - -func getProfileStringArray(profile *structpb.Struct, k string) ([]string, bool) { - var values []string - if profile == nil { - return nil, false - } - - v, ok := profile.Fields[k] - if !ok { - return nil, false - } - - s, ok := v.Kind.(*structpb.Value_ListValue) - if !ok { - return nil, false - } - - for _, v := range s.ListValue.Values { - if strVal := v.GetStringValue(); strVal != "" { - values = append(values, strVal) - } - } - - return values, true -} - -func stringSliceToInterfaceSlice(s []string) []interface{} { - var i []interface{} - for _, v := range s { - i = append(i, v) - } - return i -} diff --git a/pkg/connector/role_test.go b/pkg/connector/role_test.go new file mode 100644 index 00000000..7a0d4719 --- /dev/null +++ b/pkg/connector/role_test.go @@ -0,0 +1,73 @@ +package connector + +import ( + "context" + "testing" + + "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestRoleGrantRevoke(t *testing.T) { + ctx, done := context.WithCancel(context.Background()) + defer done() + + ctx = ctxzap.ToContext(ctx, zap.Must(zap.NewDevelopment())) + + connector, err := createConnector(ctx, t, "roles.ldif") + require.NoError(t, err) + + rb := roleBuilder(connector.client, connector.config.RoleSearchDN) + + roles, pt, _, err := rb.List(ctx, nil, &pagination.Token{}) + require.NoError(t, err) + require.Len(t, roles, 1) + require.Empty(t, pt) + require.Equal(t, roles[0].GetDisplayName(), "managers") + + managerRole := roles[0] + + ents, pt, _, err := rb.Entitlements(ctx, managerRole, &pagination.Token{}) + require.NoError(t, err) + require.Empty(t, pt) + require.Len(t, ents, 1) + + membershipEnt := ents[0] + + grants, pt, _, err := rb.Grants(ctx, managerRole, &pagination.Token{}) + require.NoError(t, err) + require.Empty(t, pt) + require.Len(t, grants, 1) + + rogerGrant := grants[0] + _, err = rb.Revoke(ctx, rogerGrant) + require.NoError(t, err) + // test double revoke doesn't cause a hard error + _, err = rb.Revoke(ctx, rogerGrant) + require.NoError(t, err) + + // verify 0 grants + grants, pt, _, err = rb.Grants(ctx, managerRole, &pagination.Token{}) + require.NoError(t, err) + require.Empty(t, pt) + require.Len(t, grants, 0) + + _, err = rb.Grant(ctx, rogerGrant.Principal, membershipEnt) + require.NoError(t, err) + // test double revoke doesn't cause a hard error + _, err = rb.Grant(ctx, rogerGrant.Principal, membershipEnt) + require.NoError(t, err) + + // verify 1 grant + grants, pt, _, err = rb.Grants(ctx, managerRole, &pagination.Token{}) + require.NoError(t, err) + require.Empty(t, pt) + require.Len(t, grants, 1) + + // verify its roger! + require.EqualExportedValues(t, rogerGrant.Principal, grants[0].Principal) + require.EqualExportedValues(t, rogerGrant.Entitlement, grants[0].Entitlement) + require.Equal(t, rogerGrant.Id, grants[0].Id) +} diff --git a/pkg/connector/testfixtures/roles.ldif b/pkg/connector/testfixtures/roles.ldif new file mode 100644 index 00000000..499326cf --- /dev/null +++ b/pkg/connector/testfixtures/roles.ldif @@ -0,0 +1,38 @@ +version: 1 + +dn: ou=roles,dc=example,dc=org +objectclass: organizationalUnit +objectclass: top +ou: roles + +dn: cn=managers,ou=roles,dc=example,dc=org +cn: managers +objectclass: organizationalRole +objectclass: top +roleoccupant: cn=roger,ou=users,dc=example,dc=org + +dn: cn=roger,ou=users,dc=example,dc=org +cn: roger +gidnumber: 500 +givenname: Roger Rabbit +homedirectory: /home/roger +loginshell: /bin/bash +objectclass: inetOrgPerson +objectclass: posixAccount +objectclass: top +sn: Rabbit +uid: roger +uidnumber: 1000 + +dn: cn=bob,ou=users,dc=example,dc=org +cn: bob +gidnumber: 500 +givenname: Bob Burger +homedirectory: /home/bob +loginshell: /bin/bash +objectclass: inetOrgPerson +objectclass: posixAccount +objectclass: top +sn: Bob +uid: bob +uidnumber: 1000 \ No newline at end of file diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 7e58694d..e75e5efa 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -153,14 +154,25 @@ func userResource(ctx context.Context, user *ldap.Entry) (*v2.Resource, error) { firstName, lastName, displayName := parseUserNames(user) userId := user.GetAttributeValue(attrUserUID) + udn, err := ldap.CanonicalizeDN(user.DN) + if err != nil { + return nil, err + } + userDN := udn.String() + profile := map[string]interface{}{ "user_id": userId, "first_name": firstName, "last_name": lastName, - "path": user.DN, + "path": userDN, } for _, v := range user.Attributes { + // skip userPassword, msSFU30Password, etc + if strings.Contains(strings.ToLower(v.Name), "password") { + continue + } + if len(v.Values) == 1 && !containsBinaryData(v.Values[0]) { profile[v.Name] = v.Values[0] } @@ -219,11 +231,6 @@ func userResource(ctx context.Context, user *ldap.Entry) (*v2.Resource, error) { displayName = userId } - udn, err := ldap.CanonicalizeDN(user.DN) - if err != nil { - return nil, err - } - userDN := udn.String() l.Debug("creating user resource", zap.String("display_name", displayName), zap.String("user_id", userId), zap.String("user_dn", userDN)) resource, err := rs.NewUserResource( diff --git a/pkg/ldap/client.go b/pkg/ldap/client.go index 5e5cd19c..92d46156 100644 --- a/pkg/ldap/client.go +++ b/pkg/ldap/client.go @@ -82,7 +82,12 @@ func (c *Client) getConnection(ctx context.Context, isModify bool, f func(client // If we are revoking a user's membership from a resource, and the user is not a member of the resource, we don't want to return an error. // If we are adding a user to a resource, and the user is already a member of the resource, we also don't want to return an error. - if ldap.IsErrorAnyOf(err, ldap.LDAPResultAttributeOrValueExists, ldap.LDAPResultEntryAlreadyExists, ldap.LDAPResultUnwillingToPerform) && isModify { + if ldap.IsErrorAnyOf(err, + ldap.LDAPResultAttributeOrValueExists, + ldap.LDAPResultEntryAlreadyExists, + ldap.LDAPResultUnwillingToPerform, + ldap.LDAPResultNoSuchAttribute, + ) && isModify { return nil } l.Error("baton-ldap: client failed to run function", zap.Error(err)) diff --git a/pkg/ldap/dn.go b/pkg/ldap/dn.go index cf3274a6..47cf3246 100644 --- a/pkg/ldap/dn.go +++ b/pkg/ldap/dn.go @@ -6,7 +6,7 @@ import ( ldap3 "github.com/go-ldap/ldap/v3" ) -// List of attribute types whose values should be made lowercase +// List of attribute types whose values should be made lowercase. var caseInsensitiveAttrs = map[string]bool{ "cn": true, "uid": true,