diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 185171bbe6..5caa1bc9b6 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -150,8 +150,8 @@ class UsersFilter(FilterSet): is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") groups_by_name = ModelMultipleChoiceFilter( - field_name="ak_groups__username", - to_field_name="username", + field_name="ak_groups__name", + to_field_name="name", queryset=Group.objects.all(), ) groups_by_pk = ModelMultipleChoiceFilter( diff --git a/internal/outpost/ldap/api.go b/internal/outpost/ldap/api.go index f769969c70..b42356048f 100644 --- a/internal/outpost/ldap/api.go +++ b/internal/outpost/ldap/api.go @@ -15,6 +15,12 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + UsersOU = "users" + GroupsOU = "groups" + VirtualGroupsOU = "virtual-groups" +) + func (ls *LDAPServer) Refresh() error { outposts, _, err := ls.ac.Client.OutpostsApi.OutpostsLdapList(context.Background()).Execute() if err != nil { @@ -25,11 +31,13 @@ func (ls *LDAPServer) Refresh() error { } providers := make([]*ProviderInstance, len(outposts.Results)) for idx, provider := range outposts.Results { - userDN := strings.ToLower(fmt.Sprintf("ou=users,%s", *provider.BaseDn)) - groupDN := strings.ToLower(fmt.Sprintf("ou=groups,%s", *provider.BaseDn)) + userDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", UsersOU, *provider.BaseDn)) + groupDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", GroupsOU, *provider.BaseDn)) + virtualGroupDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", VirtualGroupsOU, *provider.BaseDn)) logger := log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name) providers[idx] = &ProviderInstance{ BaseDN: *provider.BaseDn, + VirtualGroupDN: virtualGroupDN, GroupDN: groupDN, UserDN: userDN, appSlug: provider.ApplicationSlug, diff --git a/internal/outpost/ldap/instance_search.go b/internal/outpost/ldap/instance_search.go index 1453510f4c..b18235f060 100644 --- a/internal/outpost/ldap/instance_search.go +++ b/internal/outpost/ldap/instance_search.go @@ -59,6 +59,10 @@ func (pi *ProviderInstance) Search(req SearchRequest) (ldap.ServerSearchResult, return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter) } + // Create a custom client to set additional headers + c := api.NewAPIClient(pi.s.ac.Client.GetConfig()) + c.GetConfig().AddDefaultHeader("X-authentik-outpost-ldap-query", req.Filter) + switch filterEntity { default: return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, req.Filter) @@ -72,7 +76,12 @@ func (pi *ProviderInstance) Search(req SearchRequest) (ldap.ServerSearchResult, go func() { defer wg.Done() gapisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.api_group") - groups, _, err := parseFilterForGroup(pi.s.ac.Client.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter).Execute() + searchReq, skip := parseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter, false) + if skip { + pi.log.Trace("Skip backend request") + return + } + groups, _, err := searchReq.Execute() gapisp.Finish() if err != nil { req.log.WithError(err).Warning("failed to get groups") @@ -88,7 +97,12 @@ func (pi *ProviderInstance) Search(req SearchRequest) (ldap.ServerSearchResult, go func() { defer wg.Done() uapisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.api_user") - users, _, err := parseFilterForUser(pi.s.ac.Client.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter).Execute() + searchReq, skip := parseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) + if skip { + pi.log.Trace("Skip backend request") + return + } + users, _, err := searchReq.Execute() uapisp.Finish() if err != nil { req.log.WithError(err).Warning("failed to get users") @@ -103,7 +117,12 @@ func (pi *ProviderInstance) Search(req SearchRequest) (ldap.ServerSearchResult, entries = append(gEntries, uEntries...) case UserObjectClass, "": uapisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.api_user") - users, _, err := parseFilterForUser(pi.s.ac.Client.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter).Execute() + searchReq, skip := parseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) + if skip { + pi.log.Trace("Skip backend request") + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil + } + users, _, err := searchReq.Execute() uapisp.Finish() if err != nil { diff --git a/internal/outpost/ldap/instance_search_group.go b/internal/outpost/ldap/instance_search_group.go index 44d71a2945..7bbf14da57 100644 --- a/internal/outpost/ldap/instance_search_group.go +++ b/internal/outpost/ldap/instance_search_group.go @@ -7,52 +7,62 @@ import ( "goauthentik.io/api" ) -func parseFilterForGroup(req api.ApiCoreGroupsListRequest, f *ber.Packet) api.ApiCoreGroupsListRequest { +func parseFilterForGroup(req api.ApiCoreGroupsListRequest, f *ber.Packet, skip bool) (api.ApiCoreGroupsListRequest, bool) { switch f.Tag { case ldap.FilterEqualityMatch: return parseFilterForGroupSingle(req, f) case ldap.FilterAnd: for _, child := range f.Children { - req = parseFilterForGroup(req, child) + r, s := parseFilterForGroup(req, child, skip) + skip = skip || s + req = r } - return req + return req, skip } - return req + return req, skip } -func parseFilterForGroupSingle(req api.ApiCoreGroupsListRequest, f *ber.Packet) api.ApiCoreGroupsListRequest { +func parseFilterForGroupSingle(req api.ApiCoreGroupsListRequest, f *ber.Packet) (api.ApiCoreGroupsListRequest, bool) { // We can only handle key = value pairs here if len(f.Children) < 2 { - return req + return req, false } k := f.Children[0].Value // Ensure key is string if _, ok := k.(string); !ok { - return req + return req, false } v := f.Children[1].Value // Null values are ignored if v == nil { - return req + return req, false } // Switch on type of the value, then check the key switch vv := v.(type) { case string: switch k { case "cn": - return req.Name(vv) + return req.Name(vv), false case "member": + fallthrough case "memberOf": userDN, err := goldap.ParseDN(vv) if err != nil { - return req + return req.MembersByUsername([]string{vv}), false } username := userDN.RDNs[0].Attributes[0].Value - return req.MembersByUsername([]string{username}) + // If the DN's first ou is virtual-groups, ignore this filter + if len(userDN.RDNs) > 1 { + if userDN.RDNs[1].Attributes[0].Value == VirtualGroupsOU || userDN.RDNs[1].Attributes[0].Value == GroupsOU { + // Since we know we're not filtering anything, skip this request + return req, true + } + } + return req.MembersByUsername([]string{username}), false } // TODO: Support int default: - return req + return req, false } - return req + return req, false } diff --git a/internal/outpost/ldap/instance_search_user.go b/internal/outpost/ldap/instance_search_user.go index fd0fd3ce4a..842151a329 100644 --- a/internal/outpost/ldap/instance_search_user.go +++ b/internal/outpost/ldap/instance_search_user.go @@ -7,57 +7,67 @@ import ( "goauthentik.io/api" ) -func parseFilterForUser(req api.ApiCoreUsersListRequest, f *ber.Packet) api.ApiCoreUsersListRequest { +func parseFilterForUser(req api.ApiCoreUsersListRequest, f *ber.Packet, skip bool) (api.ApiCoreUsersListRequest, bool) { switch f.Tag { case ldap.FilterEqualityMatch: return parseFilterForUserSingle(req, f) case ldap.FilterAnd: for _, child := range f.Children { - req = parseFilterForUser(req, child) + r, s := parseFilterForUser(req, child, skip) + skip = skip || s + req = r } - return req + return req, skip } - return req + return req, skip } -func parseFilterForUserSingle(req api.ApiCoreUsersListRequest, f *ber.Packet) api.ApiCoreUsersListRequest { +func parseFilterForUserSingle(req api.ApiCoreUsersListRequest, f *ber.Packet) (api.ApiCoreUsersListRequest, bool) { // We can only handle key = value pairs here if len(f.Children) < 2 { - return req + return req, false } k := f.Children[0].Value // Ensure key is string if _, ok := k.(string); !ok { - return req + return req, false } v := f.Children[1].Value // Null values are ignored if v == nil { - return req + return req, false } // Switch on type of the value, then check the key switch vv := v.(type) { case string: switch k { case "cn": - return req.Username(vv) + return req.Username(vv), false case "name": case "displayName": - return req.Name(vv) + return req.Name(vv), false case "mail": - return req.Email(vv) + return req.Email(vv), false case "member": + fallthrough case "memberOf": groupDN, err := goldap.ParseDN(vv) if err != nil { - return req + return req.GroupsByName([]string{vv}), false } name := groupDN.RDNs[0].Attributes[0].Value - return req.GroupsByName([]string{name}) + // If the DN's first ou is virtual-groups, ignore this filter + if len(groupDN.RDNs) > 1 { + if groupDN.RDNs[1].Attributes[0].Value == UsersOU || groupDN.RDNs[1].Attributes[0].Value == VirtualGroupsOU { + // Since we know we're not filtering anything, skip this request + return req, true + } + } + return req.GroupsByName([]string{name}), false } // TODO: Support int default: - return req + return req, false } - return req + return req, false } diff --git a/internal/outpost/ldap/ldap.go b/internal/outpost/ldap/ldap.go index 6d7a72c7b5..ebfa0e3c07 100644 --- a/internal/outpost/ldap/ldap.go +++ b/internal/outpost/ldap/ldap.go @@ -19,8 +19,10 @@ const UserObjectClass = "user" type ProviderInstance struct { BaseDN string - UserDN string - GroupDN string + UserDN string + + VirtualGroupDN string + GroupDN string appSlug string flowSlug string diff --git a/internal/outpost/ldap/utils.go b/internal/outpost/ldap/utils.go index 67cad4f2ea..3a9840baad 100644 --- a/internal/outpost/ldap/utils.go +++ b/internal/outpost/ldap/utils.go @@ -95,7 +95,7 @@ func (pi *ProviderInstance) APIGroupToLDAPGroup(g api.Group) LDAPGroup { func (pi *ProviderInstance) APIUserToLDAPGroup(u api.User) LDAPGroup { return LDAPGroup{ - dn: pi.GetGroupDN(u.Username), + dn: pi.GetVirtualGroupDN(u.Username), cn: u.Username, uid: u.Uid, gidNumber: pi.GetUidNumber(u), @@ -114,6 +114,10 @@ func (pi *ProviderInstance) GetGroupDN(group string) string { return fmt.Sprintf("cn=%s,%s", group, pi.GroupDN) } +func (pi *ProviderInstance) GetVirtualGroupDN(group string) string { + return fmt.Sprintf("cn=%s,%s", group, pi.VirtualGroupDN) +} + func (pi *ProviderInstance) GetUidNumber(user api.User) string { return strconv.FormatInt(int64(pi.uidStartNumber+user.Pk), 10) } diff --git a/website/docs/providers/ldap.md b/website/docs/providers/ldap.md index 2aead1e105..87beeeecfb 100644 --- a/website/docs/providers/ldap.md +++ b/website/docs/providers/ldap.md @@ -14,7 +14,7 @@ Binding against the LDAP Server uses a flow in the background. This allows you t You can configure under which base DN the information should be available. For this documentation we'll use the default of `DC=ldap,DC=goauthentik,DC=io`. -Users are available under `ou=users,` and groups under `ou=groups,`. +Users are available under `ou=users,` and groups under `ou=groups,`. To aid compatibility, each user belongs to its own "virtual" group, as is standard on most Unix-like systems. This group does not exist in the authentik database, and is generated on the fly. These virtual groups are under the `ou=virtual-groups,` DN. You can bind using the DN `cn=,ou=users,`, or using the following ldapsearch command for example: