mirror of
https://github.com/netauth/ldap.git
synced 2026-11-03 11:43:44 -06:00
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:
parent
d74308280b
commit
31b9b295aa
16
AGENTS.md
16
AGENTS.md
|
|
@ -100,6 +100,22 @@ goroutines with no recovery of its own).
|
||||||
domain or empty labels; searches do not route until it succeeds. Naming
|
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:
|
context is always `dc=netauth,dc=<label>,...`. Tree layout under it is fixed:
|
||||||
`uid=<id>,ou=entities,<base>` and `cn=<name>,ou=groups,<base>`.
|
`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
|
- **Bind state is per connection**: searches (except RootDSE) are refused with
|
||||||
`insufficientAccessRights` until a successful bind on the same connection
|
`insufficientAccessRights` until a successful bind on the same connection
|
||||||
(`conn.go`). A failed bind clears the state per RFC 4511. Anonymous binds
|
(`conn.go`). A failed bind clears the state per RFC 4511. Anonymous binds
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,17 @@ func (s *server) buildRoutes() {
|
||||||
Label("Search - Groups")
|
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
|
s.routes = routes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,164 @@ func (s *server) handleSearchDSE(w ldap.ResponseWriter, m *ldap.Message) {
|
||||||
w.Write(res)
|
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) {
|
func (s *server) handleSearchEntities(w ldap.ResponseWriter, m *ldap.Message) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -81,12 +239,19 @@ func (s *server) handleSearchEntities(w ldap.ResponseWriter, m *ldap.Message) {
|
||||||
w.Write(res)
|
w.Write(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// entitySearchResult maps an entity onto a SearchResultEntry. The
|
// entitySearchResult maps an entity from a subtree search onto a
|
||||||
// additional lookup needed to populate memberOf is only performed when
|
// SearchResultEntry, deriving the entry DN from the search base.
|
||||||
// the client actually asked for that attribute, since it costs one
|
|
||||||
// NetAuth round trip per entity.
|
|
||||||
func (s *server) entitySearchResult(ctx context.Context, e *pb.Entity, dn message.LDAPDN, attrs message.AttributeSelection) (message.SearchResultEntry, error) {
|
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("uid", message.AttributeValue(e.GetID()))
|
||||||
res.AddAttribute("uidNumber", message.AttributeValue(strconv.Itoa(int(e.GetNumber()))))
|
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)
|
w.Write(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupSearchResult maps a group onto a SearchResultEntry. The
|
// groupSearchResult maps a group from a subtree search onto a
|
||||||
// additional lookup needed to populate member is only performed when
|
// SearchResultEntry, deriving the entry DN from the search base.
|
||||||
// the client actually asked for that attribute, since it costs one
|
|
||||||
// NetAuth round trip per group.
|
|
||||||
func (s *server) groupSearchResult(ctx context.Context, g *pb.Group, dn message.LDAPDN, attrs message.AttributeSelection) (message.SearchResultEntry, error) {
|
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("cn", message.AttributeValue(g.GetName()))
|
||||||
res.AddAttribute("gidNumber", message.AttributeValue(strconv.Itoa(int(g.GetNumber()))))
|
res.AddAttribute("gidNumber", message.AttributeValue(strconv.Itoa(int(g.GetNumber()))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,56 @@ func (s *server) entityIDFromDN(dn message.LDAPDN) (string, error) {
|
||||||
|
|
||||||
return strings.TrimPrefix(parts[0], "uid="), nil
|
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
320
internal/ldap/vault_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue