internal/ldap: Answer base-object searches for one entry

HashiCorp Vault reads the attributes of the user it just bound as by
issuing a base-object "(objectClass=*)" search whose base DN is the
user's own DN, and has no option to turn that step off. Those searches
named a leaf entry rather than a container, so nothing routed them and
login failed with unwillingToPerform.

A single entry is now looked up by ID or name and returned under the DN
the client asked for, advertising objectClass as the other entries do.
An unknown name reports noSuchObject so clients can distinguish it from
an empty result, an entry that does not match the filter succeeds with
no entries as RFC 4511 requires, and filter shapes we cannot evaluate
are refused instead of guessed at. The catch-all route is limited to
base-object scope so searches with an unknown subtree base are still
refused, which keeps a renamed server from answering its old base DN.

💘 Generated with Crush

Assisted-by: Crush:qwen38_flash_next_mtp
This commit is contained in:
Michael Aldridge 2026-11-02 19:59:53 -06:00
parent d74308280b
commit 31b9b295aa
5 changed files with 582 additions and 10 deletions

View file

@ -100,6 +100,22 @@ goroutines with no recovery of its own).
domain or empty labels; searches do not route until it succeeds. Naming
context is always `dc=netauth,dc=<label>,...`. Tree layout under it is fixed:
`uid=<id>,ou=entities,<base>` and `cn=<name>,ou=groups,<base>`.
- **Single-entry (base-object) searches are served**: `handleSearchEntry`
(search.go) answers a base-object search addressed at one entity or
group DN, which is how HashiCorp Vault reads the bound user's
attributes (it always sends `base=<user DN>`, `scope=base`,
`(objectClass=*)` on every login and there is no Vault knob to turn
that off). It is routed by a catch-all `routes.Search(...)` with **no
BaseDn** and `Scope(SearchRequestScopeBaseObject)`, registered last
so the specific routes win; the DN itself is dispatched by
`entryFromDN` (util.go). The scope restriction is load-bearing:
without it any stale subtree base would match and return
`searchResDone(50)` instead of the `53` that
`TestSetDomainReplacesRoutes` asserts. Semantics: unknown name →
`noSuchObject`(32); entry that does not match the filter → success
with zero entries (RFC 4511); filter forms outside the small set in
`entryFilterMatches` → `unwillingToPerform`(53) rather than a wrong
answer. Entries carry `objectClass: top, extensibleObject`.
- **Bind state is per connection**: searches (except RootDSE) are refused with
`insufficientAccessRights` until a successful bind on the same connection
(`conn.go`). A failed bind clears the state per RFC 4511. Anonymous binds

View file

@ -57,6 +57,17 @@ func (s *server) buildRoutes() {
Label("Search - Groups")
}
// Catch-all route for single-entry lookups, registered last so
// the specific routes above win. Clients such as HashiCorp Vault
// address one entry directly with a base-object search whose base
// DN is the entry itself; that DN has no fixed value to match on,
// so only the scope can be matched here and the DN is dispatched
// inside the handler. Subtree searches with an unknown base stay
// unrouted and are still refused outright.
routes.Search(s.guard(s.handleSearchEntry)).
Scope(ldap.SearchRequestScopeBaseObject).
Label("Search - Single Entry")
s.routes = routes
}

View file

@ -28,6 +28,164 @@ func (s *server) handleSearchDSE(w ldap.ResponseWriter, m *ldap.Message) {
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=<name>,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()
@ -81,12 +239,19 @@ func (s *server) handleSearchEntities(w ldap.ResponseWriter, m *ldap.Message) {
w.Write(res)
}
// entitySearchResult maps an entity onto a SearchResultEntry. The
// additional lookup needed to populate memberOf is only performed when
// the client actually asked for that attribute, since it costs one
// NetAuth round trip per entity.
// 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) {
res := ldap.NewSearchResultEntry("uid=" + e.GetID() + "," + string(dn))
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()))))
@ -162,12 +327,19 @@ func (s *server) handleSearchGroups(w ldap.ResponseWriter, m *ldap.Message) {
w.Write(res)
}
// groupSearchResult maps a group onto a SearchResultEntry. The
// additional lookup needed to populate member is only performed when
// the client actually asked for that attribute, since it costs one
// NetAuth round trip per group.
// 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) {
res := ldap.NewSearchResultEntry("cn=" + g.GetName() + "," + string(dn))
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()))))

View file

@ -41,3 +41,56 @@ func (s *server) entityIDFromDN(dn message.LDAPDN) (string, error) {
return strings.TrimPrefix(parts[0], "uid="), nil
}
// errUnsupportedFilter marks a filter this bridge cannot evaluate.
// Returning it lets callers answer with an explicit error rather than
// an accidentally empty result.
var errUnsupportedFilter = errors.New("unsupported search filter")
type entryKind int
const (
kindEntity entryKind = iota
kindGroup
)
// entryRef names the single entry a DN addresses directly, as opposed
// to a container DN that heads a subtree search.
type entryRef struct {
kind entryKind
name string
}
// entryFromDN parses a DN that names one entity or group outright,
// e.g. uid=jsmith,ou=entities,dc=netauth,dc=example,dc=com. Clients
// that already know a DN (HashiCorp Vault does this on every login)
// use such DNs as the base of a base-object search.
func (s *server) entryFromDN(dn message.LDAPDN) (*entryRef, error) {
parts := strings.Split(string(dn), ",")
for i := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
}
if len(parts) < 3 {
return nil, errors.New("malformed DN")
}
rest := parts[2:]
if len(rest) != len(s.nc) {
return nil, errors.New("queries must be rooted at " + strings.Join(s.nc, ","))
}
for i, p := range rest {
if p != s.nc[i] {
return nil, errors.New("queries must be rooted at " + strings.Join(s.nc, ","))
}
}
switch {
case strings.HasPrefix(parts[0], "uid=") && parts[1] == "ou=entities":
return &entryRef{kind: kindEntity, name: strings.TrimPrefix(parts[0], "uid=")}, nil
case strings.HasPrefix(parts[0], "cn=") && parts[1] == "ou=groups":
return &entryRef{kind: kindGroup, name: strings.TrimPrefix(parts[0], "cn=")}, nil
}
return nil, errors.New("DN does not name an entity or a group")
}

320
internal/ldap/vault_test.go Normal file
View file

@ -0,0 +1,320 @@
package ldap
import (
"bytes"
"testing"
"github.com/vjeantet/goldap/message"
ldap "github.com/vjeantet/ldapserver"
pb "github.com/netauth/protocol"
)
// berBaseObjectSearch encodes a base-object search for a single entry
// with an arbitrary filter element. This is the shape HashiCorp Vault
// sends on every login to read the attributes of the bound user.
func berBaseObjectSearch(t *testing.T, baseDN string, filterElement []byte) []byte {
t.Helper()
search := berElement(0x63, // [APPLICATION 3] constructed
berOctetString(baseDN), // baseObject
berElement(0x0a, []byte{0}), // scope: baseObject
berElement(0x0a, []byte{0}), // derefAliases: never
berInteger(0), // sizeLimit
berInteger(0), // timeLimit
berElement(0x01, []byte{0}), // typesOnly: false
filterElement, // filter
berElement(0x30), // empty attribute selection (all)
)
return berElement(0x30, berInteger(2), search)
}
// objectClassPresentFilter encodes the (objectClass=*) filter.
func objectClassPresentFilter() []byte { return berRawPresent("objectClass") }
// entryContains reports whether the encoded form of an entry contains
// an OCTET STRING holding exactly the given text.
func entryContains(t *testing.T, po message.ProtocolOp, text string) bool {
t.Helper()
m := message.NewLDAPMessageWithProtocolOp(po)
m.SetMessageID(2)
b, err := m.Write()
if err != nil {
t.Fatalf("encoding entry: %s", err)
}
// The value is encoded as a bare OCTET STRING with its exact
// length, which avoids matching a substring of a longer value.
want := []byte{0x04, byte(len(text))}
want = append(want, text...)
return bytes.Contains(b.Bytes(), want)
}
// entryHasAttribute encodes a SearchResultEntry and reports whether it
// carries the named attribute. goldap exposes no accessors for entry
// attributes, so the encoded form is inspected instead.
func entryHasAttribute(t *testing.T, po message.ProtocolOp, attr string) bool {
t.Helper()
return entryContains(t, po, attr)
}
// entryContainsDN reports whether an entry is recorded under the given
// distinguished name.
func entryContainsDN(t *testing.T, po message.ProtocolOp, dn string) bool {
t.Helper()
return entryContains(t, po, dn)
}
func vaultTestServer(c naClient) *server {
s := boundTestServer(c)
s.nc = []string{"dc=netauth", "dc=voidlinux", "dc=org"}
return s
}
// vaultBind authenticates the fixture naming context so the search is
// not refused before it reaches the handler logic under test. The
// shared bind helper uses a different naming context and cannot be
// used here.
func vaultBind(t *testing.T, s *server) {
t.Helper()
s.handleBind(&recordingWriter{}, mustDecode(t,
berBindRequest("uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", "hunter2")))
if !s.bound(&ldap.Message{}) {
t.Fatal("the fixture bind did not mark the connection bound")
}
}
// A base-object search for a known entity returns exactly that entry
// terminated by a searchResDone. Vault reads the entity alias out of
// the returned attributes, so failing to answer this aborts login.
func TestBaseObjectSearchReturnsEntry(t *testing.T) {
c := &fakeClient{
entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)},
}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(w.responses) != 2 {
t.Fatalf("got %d responses, want 1 entry + 1 done", len(w.responses))
}
entry, ok := w.responses[0].(message.SearchResultEntry)
if !ok {
t.Fatalf("first response is %T, want SearchResultEntry", w.responses[0])
}
if _, ok := w.responses[1].(message.SearchResultDone); !ok {
t.Errorf("second response is %T, want SearchResultDone", w.responses[1])
}
// Vault requires the DN it asked for, not one rebuilt from the
// search base, and needs the userattr value to be present.
if !entryContainsDN(t, entry, "uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org") {
t.Errorf("entry DN is not the requested DN")
}
for _, attr := range []string{"uid", "objectClass"} {
if !entryHasAttribute(t, entry, attr) {
t.Errorf("entry is missing the %q attribute", attr)
}
}
}
// The alias Vault derives comes from the attribute the entry carries,
// so an entity whose NetAuth ID differs in case from the requested DN
// must still resolve.
func TestBaseObjectSearchIsCaseInsensitive(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=Maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if code := resultCode(t, w.responses[len(w.responses)-1]); code != ldap.LDAPResultSuccess {
t.Fatalf("result code is %d, want success", code)
}
if len(w.responses) != 2 {
t.Errorf("got %d responses, want the entry plus a done", len(w.responses))
}
}
// A base-object search for an unknown name returns noSuchObject rather
// than an empty success, so clients can tell the difference.
func TestBaseObjectSearchUnknownEntity(t *testing.T) {
s := vaultTestServer(&fakeClient{})
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=ghost,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(w.responses) != 1 {
t.Fatalf("got %d responses, want only a done", len(w.responses))
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultNoSuchObject {
t.Errorf("result code is %d, want %d (noSuchObject)", code, ldap.LDAPResultNoSuchObject)
}
}
// RFC 4511: a base-object search whose entry does not match the filter
// succeeds with zero entries.
func TestBaseObjectSearchFilterMismatch(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org",
berRawEquality("uid", "someoneelse"))))
if len(w.responses) != 1 {
t.Fatalf("got %d responses, want only a done", len(w.responses))
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultSuccess {
t.Errorf("result code is %d, want success with no entries", code)
}
}
func TestBaseObjectSearchRequiresBind(t *testing.T) {
s := vaultTestServer(&fakeClient{})
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if code := resultCode(t, w.responses[len(w.responses)-1]); code != ldap.LDAPResultInsufficientAccessRights {
t.Errorf("result code is %d, want %d (insufficientAccessRights)",
code, ldap.LDAPResultInsufficientAccessRights)
}
}
// A base-object search on a DN that is neither an entity nor a group
// (the naming context itself, an ou) is refused without ever reaching
// NetAuth.
func TestBaseObjectSearchOnContainerDN(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(c.entitySearchResults) == 0 {
t.Error("the fake client should still hold its fixture data")
}
if code := resultCode(t, w.responses[len(w.responses)-1]); code != ldap.LDAPResultNoSuchObject {
t.Errorf("result code is %d, want %d (noSuchObject)", code, ldap.LDAPResultNoSuchObject)
}
if len(w.responses) != 1 {
t.Errorf("got %d responses, want only a done", len(w.responses))
}
}
// Group DNs are served the same way entity DNs are.
func TestBaseObjectSearchGroup(t *testing.T) {
c := &fakeClient{
groupSearchResults: []*pb.Group{testGroup("admins", 42)},
groupMembersResults: map[string][]*pb.Entity{"admins": {testEntity("maldridge", 1001)}},
}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"cn=admins,ou=groups,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(w.responses) != 2 {
t.Fatalf("got %d responses, want 1 entry + 1 done", len(w.responses))
}
entry, ok := w.responses[0].(message.SearchResultEntry)
if !ok {
t.Fatalf("first response is %T, want SearchResultEntry", w.responses[0])
}
if !entryHasAttribute(t, entry, "cn") {
t.Error("group entry is missing the cn attribute")
}
if !entryHasAttribute(t, entry, "member") {
t.Error("group entry is missing the member attribute")
}
}
// The single-entry handler is reached through the real mux, and adding
// it must not widen what other searches are accepted: an empty base or
// a stale base DN at subtree scope is still refused.
func TestBaseObjectSearchRouting(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := New(WithLogger(testLogger()), WithNetAuth(c))
if err := s.SetDomain("voidlinux.org"); err != nil {
t.Fatal(err)
}
fc := &fakeConn{}
s.conn = func(*ldap.Message) connection { return fc }
bind := &recordingWriter{}
s.ServeLDAP(bind, mustDecode(t,
berBindRequest("uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", "hunter2")))
if code := resultCode(t, bind.responses[len(bind.responses)-1]); code != ldap.LDAPResultSuccess {
t.Fatalf("bind result code is %d, want success", code)
}
entry := &recordingWriter{}
s.ServeLDAP(entry, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(entry.responses) != 2 {
t.Fatalf("got %d responses through the mux, want 1 entry + 1 done", len(entry.responses))
}
if _, ok := entry.responses[0].(message.SearchResultEntry); !ok {
t.Errorf("first response is %T, want SearchResultEntry", entry.responses[0])
}
for _, base := range []string{"", "ou=entities,dc=netauth,dc=example,dc=com"} {
w := &recordingWriter{}
s.ServeLDAP(w, mustDecode(t, berSearchRequest(base)))
if len(w.responses) != 1 {
t.Fatalf("base %q: got %d responses, want only a done", base, len(w.responses))
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultUnwillingToPerform {
t.Errorf("base %q: result code is %d, want %d (unwillingToPerform)",
base, code, ldap.LDAPResultUnwillingToPerform)
}
}
}
func TestEntryFromDN(t *testing.T) {
s := vaultTestServer(&fakeClient{})
ok := map[string]entryKind{
"uid=jsmith,ou=entities,dc=netauth,dc=voidlinux,dc=org": kindEntity,
"cn=admins,ou=groups,dc=netauth,dc=voidlinux,dc=org": kindGroup,
"uid=jsmith,ou=ENTITIES,dc=netauth,dc=voidlinux,dc=org": kindEntity,
}
for dn, want := range ok {
ref, err := s.entryFromDN(message.LDAPDN(dn))
if err != nil {
t.Errorf("entryFromDN(%q): %s", dn, err)
continue
}
if ref.kind != want {
t.Errorf("entryFromDN(%q) kind is %v, want %v", dn, ref.kind, want)
}
}
bad := []string{
"",
"uid=jsmith",
"uid=jsmith,ou=entities",
"ou=entities,dc=netauth,dc=voidlinux,dc=org",
"cn=admins,ou=entities,dc=netauth,dc=voidlinux,dc=org",
"uid=jsmith,ou=entities,dc=netauth,dc=example,dc=com",
}
for _, dn := range bad {
if ref, err := s.entryFromDN(message.LDAPDN(dn)); err == nil {
t.Errorf("entryFromDN(%q) accepted a non-entry DN (%+v)", dn, ref)
}
}
}