package ldap import ( "context" "strconv" "strings" "github.com/vjeantet/goldap/message" ldap "github.com/vjeantet/ldapserver" "github.com/netauth/ldap/internal/buildinfo" pb "github.com/netauth/protocol" ) func (s *server) handleSearchDSE(w ldap.ResponseWriter, m *ldap.Message) { nc := strings.Join(s.nc, ", ") e := ldap.NewSearchResultEntry("") e.AddAttribute("vendorName", "NetAuth") e.AddAttribute("vendorVersion", message.AttributeValue(buildinfo.Version)) e.AddAttribute("objectClass", "top", "extensibleObject") e.AddAttribute("supportedLDAPVersion", "3") e.AddAttribute("namingContexts", message.AttributeValue(nc)) w.Write(e) res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess) w.Write(res) } // objectClasses are the structural classes advertised on every entry. // NetAuth has no schema, so entries are presented as the least // surprising generic classes. var objectClasses = []message.AttributeValue{"top", "extensibleObject"} // handleSearchEntry answers a base-object search addressed at a single // entity or group DN. Some clients (HashiCorp Vault among them) read // the attributes of one known entry with a base-scope // "(objectClass=*)" search instead of a subtree search, and NetAuth // has no per-entry lookup, so the entry is fetched by its ID or name // and then checked against the filter. func (s *server) handleSearchEntry(w ldap.ResponseWriter, m *ldap.Message) { ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout) defer cancel() if !s.bound(m) { s.l.Warn("Search from an unauthenticated connection") res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultInsufficientAccessRights) res.SetDiagnosticMessage("a bind is required before searching") w.Write(res) return } r := m.GetSearchRequest() dn := string(r.BaseObject()) ref, err := s.entryFromDN(r.BaseObject()) if err != nil { s.l.Debug("Base scope search for something we do not have", "dn", dn) writeSearchDone(w, ldap.LDAPResultNoSuchObject, "only entity and group DNs can be searched directly, e.g. uid=,ou=entities,"+strings.Join(s.nc, ",")) return } entry, found, err := s.fetchEntry(ctx, ref, dn, r.Attributes()) if err != nil { s.l.Warn("NetAuth entry lookup failed", "dn", dn, "error", err) writeSearchDone(w, ldap.LDAPResultOperationsError, err.Error()) return } if !found { writeSearchDone(w, ldap.LDAPResultNoSuchObject, "") return } match, err := entryFilterMatches(r.Filter(), ref.name) if err != nil { s.l.Warn("Unsupported filter on a base scope search", "dn", dn, "error", err) writeSearchDone(w, ldap.LDAPResultUnwillingToPerform, "Filter type not supported") return } // RFC 4511: a base-object search whose entry does not match the // filter succeeds and returns zero entries. if !match { writeSearchDone(w, ldap.LDAPResultSuccess, "") return } w.Write(entry) writeSearchDone(w, ldap.LDAPResultSuccess, "") } // writeSearchDone terminates a search operation. func writeSearchDone(w ldap.ResponseWriter, code int, diagnostic string) { res := ldap.NewSearchResultDoneResponse(code) if diagnostic != "" { res.SetDiagnosticMessage(diagnostic) } w.Write(res) } // fetchEntry looks up a single entity or group in NetAuth and renders // it under the exact DN the client asked for. The boolean reports // whether NetAuth knows the name at all. func (s *server) fetchEntry(ctx context.Context, ref *entryRef, dn string, attrs message.AttributeSelection) (message.SearchResultEntry, bool, error) { switch ref.kind { case kindEntity: expr, err := mapToBleveStringQuery("uid", ref.name) if err != nil { return message.SearchResultEntry{}, false, err } ents, err := s.c.EntitySearch(ctx, expr) if err != nil { return message.SearchResultEntry{}, false, err } for i := range ents { if !strings.EqualFold(ents[i].GetID(), ref.name) { continue } entry, err := s.entityAttributes(ctx, ents[i], dn, attrs) return entry, err == nil, err } case kindGroup: expr, err := mapToBleveStringQuery("cn", ref.name) if err != nil { return message.SearchResultEntry{}, false, err } grps, err := s.c.GroupSearch(ctx, expr) if err != nil { return message.SearchResultEntry{}, false, err } for i := range grps { if !strings.EqualFold(grps[i].GetName(), ref.name) { continue } entry, err := s.groupAttributes(ctx, grps[i], dn, attrs) return entry, err == nil, err } } return message.SearchResultEntry{}, false, nil } // entryFilterMatches reports whether a search filter selects a single // entry whose identifying attribute has the given name. Only the // filter forms this bridge models are evaluated; anything else is an // error so that a client is never handed a wrong answer instead. func entryFilterMatches(f message.Filter, name string) (bool, error) { switch f := f.(type) { case message.FilterPresent: switch strings.ToLower(string(f)) { case "objectclass", "uid", "cn": return true, nil } return false, errUnsupportedFilter case message.FilterEqualityMatch: value := string(f.AssertionValue()) switch strings.ToLower(string(f.AttributeDesc())) { case "uid", "cn": return strings.EqualFold(value, name), nil case "objectclass": return strings.EqualFold(value, "top") || strings.EqualFold(value, "extensibleObject"), nil } return false, errUnsupportedFilter case message.FilterAnd: for _, sub := range f { ok, err := entryFilterMatches(sub, name) if err != nil || !ok { return false, err } } return true, nil case message.FilterOr: for _, sub := range f { ok, err := entryFilterMatches(sub, name) if err != nil { return false, err } if ok { return true, nil } } return false, nil case message.FilterNot: ok, err := entryFilterMatches(f.Filter, name) return !ok, err } return false, errUnsupportedFilter } func (s *server) handleSearchEntities(w ldap.ResponseWriter, m *ldap.Message) { ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout) defer cancel() s.l.Debug("Search Entities") if !s.bound(m) { s.l.Warn("Search from an unauthenticated connection") res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultInsufficientAccessRights) res.SetDiagnosticMessage("a bind is required before searching") w.Write(res) return } r := m.GetSearchRequest() expr, err := s.buildBleveQuery(r.Filter()) if err != nil { // If err is non-nil at this point it must mean that // the above match didn't find a supported filter. s.l.Warn("Unsupported Search Filter, this is a bug, please file a report", "filter", r.Filter()) res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultUnwillingToPerform) res.SetDiagnosticMessage("Filter type not supported") w.Write(res) return } s.l.Debug("Searching entities", "query", expr) ents, err := s.c.EntitySearch(ctx, expr) if err != nil { s.l.Warn("NetAuth entity search failed", "query", expr, "error", err) res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError) res.SetDiagnosticMessage(err.Error()) w.Write(res) return } for i := range ents { e, err := s.entitySearchResult(ctx, ents[i], r.BaseObject(), r.Attributes()) if err != nil { res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError) res.SetDiagnosticMessage(err.Error()) w.Write(res) return } w.Write(e) } s.l.Debug("Entities", "res", ents) res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess) w.Write(res) } // entitySearchResult maps an entity from a subtree search onto a // SearchResultEntry, deriving the entry DN from the search base. func (s *server) entitySearchResult(ctx context.Context, e *pb.Entity, dn message.LDAPDN, attrs message.AttributeSelection) (message.SearchResultEntry, error) { return s.entityAttributes(ctx, e, "uid="+e.GetID()+","+string(dn), attrs) } // entityAttributes maps an entity onto a SearchResultEntry at the // given full DN. The extra lookup needed to populate memberOf is only // performed when the client asked for that attribute, since it costs // one NetAuth round trip per entity. func (s *server) entityAttributes(ctx context.Context, e *pb.Entity, dn string, attrs message.AttributeSelection) (message.SearchResultEntry, error) { res := ldap.NewSearchResultEntry(dn) res.AddAttribute("objectClass", objectClasses...) res.AddAttribute("uid", message.AttributeValue(e.GetID())) res.AddAttribute("uidNumber", message.AttributeValue(strconv.Itoa(int(e.GetNumber())))) if !attributeRequested(attrs, "memberOf") { return res, nil } grps, err := s.c.EntityGroups(ctx, e.GetID()) if err != nil { return res, err } memberOf := []message.AttributeValue{} for i := range grps { g := "cn=" + grps[i].GetName() + ",ou=groups," + strings.Join(s.nc, ",") memberOf = append(memberOf, message.AttributeValue(g)) } res.AddAttribute("memberOf", memberOf...) return res, nil } func (s *server) handleSearchGroups(w ldap.ResponseWriter, m *ldap.Message) { ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout) defer cancel() s.l.Debug("Search Groups") if !s.bound(m) { s.l.Warn("Search from an unauthenticated connection") res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultInsufficientAccessRights) res.SetDiagnosticMessage("a bind is required before searching") w.Write(res) return } r := m.GetSearchRequest() expr, err := s.buildBleveQuery(r.Filter()) if err != nil { // If err is non-nil at this point it must mean that // the above match didn't find a supported filter. s.l.Warn("Unsupported Search Filter, this is a bug, please file a report", "filter", r.Filter()) res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultUnwillingToPerform) res.SetDiagnosticMessage("Filter type not supported") w.Write(res) return } s.l.Debug("Searching groups", "expr", expr) groups, err := s.c.GroupSearch(ctx, expr) if err != nil { s.l.Warn("NetAuth group search failed", "query", expr, "error", err) res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError) res.SetDiagnosticMessage(err.Error()) w.Write(res) return } for i := range groups { s.l.Debug("Found group", "group", groups[i].GetName()) e, err := s.groupSearchResult(ctx, groups[i], r.BaseObject(), r.Attributes()) if err != nil { res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError) res.SetDiagnosticMessage(err.Error()) w.Write(res) return } w.Write(e) } res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess) w.Write(res) } // groupSearchResult maps a group from a subtree search onto a // SearchResultEntry, deriving the entry DN from the search base. func (s *server) groupSearchResult(ctx context.Context, g *pb.Group, dn message.LDAPDN, attrs message.AttributeSelection) (message.SearchResultEntry, error) { return s.groupAttributes(ctx, g, "cn="+g.GetName()+","+string(dn), attrs) } // groupAttributes maps a group onto a SearchResultEntry at the given // full DN. The extra lookup needed to populate member is only // performed when the client asked for that attribute, since it costs // one NetAuth round trip per group. func (s *server) groupAttributes(ctx context.Context, g *pb.Group, dn string, attrs message.AttributeSelection) (message.SearchResultEntry, error) { res := ldap.NewSearchResultEntry(dn) res.AddAttribute("objectClass", objectClasses...) res.AddAttribute("cn", message.AttributeValue(g.GetName())) res.AddAttribute("gidNumber", message.AttributeValue(strconv.Itoa(int(g.GetNumber())))) if !attributeRequested(attrs, "member") { return res, nil } members, err := s.c.GroupMembers(ctx, g.GetName()) if err != nil { return res, err } memberList := []message.AttributeValue{} for i := range members { g := "uid=" + members[i].GetID() + ",ou=entities," + strings.Join(s.nc, ",") memberList = append(memberList, message.AttributeValue(g)) } res.AddAttribute("member", memberList...) return res, nil }