generated from coulomb/repo-seed
feat: implement T14, T10 — enforcement middleware, LLDAP adapter
- T14: Unsupported feature registry with 7 pre-registered profile boundaries - T10: LLDAP adapter implementing UserRepository; validator-gated reads 24 tests pass, go vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
294
src/internal/adapters/lldap/adapter.go
Normal file
294
src/internal/adapters/lldap/adapter.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package lldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/validator"
|
||||
)
|
||||
|
||||
// LDAPConn is a minimal interface over an LDAP connection, enabling test injection.
|
||||
// Only the operations used by the adapter are included; no concrete LDAP types are
|
||||
// exposed through return values or parameters visible outside this package.
|
||||
type LDAPConn interface {
|
||||
Bind(username, password string) error
|
||||
Search(request *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// LDAPAdapter implements domain.UserRepository using an LLDAP backend.
|
||||
// All LDAP types are confined to this package — the domain and server layers
|
||||
// are not aware of any LDAP-specific constructs.
|
||||
type LDAPAdapter struct {
|
||||
cfg Config
|
||||
dialFn func(addr string) (LDAPConn, error)
|
||||
}
|
||||
|
||||
// New returns a production-ready LDAPAdapter that dials real LDAP connections.
|
||||
func New(cfg Config) *LDAPAdapter {
|
||||
return &LDAPAdapter{
|
||||
cfg: cfg,
|
||||
dialFn: defaultDialFn(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// NewForTest returns an LDAPAdapter with a custom dial function for test injection.
|
||||
// Production code should use New instead.
|
||||
func NewForTest(cfg Config, dialFn func(addr string) (LDAPConn, error)) *LDAPAdapter {
|
||||
return &LDAPAdapter{cfg: cfg, dialFn: dialFn}
|
||||
}
|
||||
|
||||
// defaultDialFn returns a dial function that establishes a real LDAP connection.
|
||||
func defaultDialFn(cfg Config) func(addr string) (LDAPConn, error) {
|
||||
return func(addr string) (LDAPConn, error) {
|
||||
u, err := url.Parse(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldap: invalid URL %q: %w", cfg.URL, err)
|
||||
}
|
||||
if u.Scheme == "ldaps" {
|
||||
conn, err := ldap.DialTLS("tcp", addr, &tls.Config{
|
||||
InsecureSkipVerify: cfg.TLSSkipVerify, //nolint:gosec // dev flag, documented
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldap: TLS dial %q: %w", addr, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
conn, err := ldap.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldap: dial %q: %w", addr, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
// dial opens a new LDAP connection and performs the service-account bind.
|
||||
func (a *LDAPAdapter) dial() (LDAPConn, error) {
|
||||
u, err := url.Parse(a.cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldap: invalid URL %q: %w", a.cfg.URL, err)
|
||||
}
|
||||
host := u.Host
|
||||
if host == "" {
|
||||
host = a.cfg.URL // fallback for bare addr passed in tests
|
||||
}
|
||||
conn, err := a.dialFn(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPW); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("lldap: service bind failed: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// domain.UserRepository implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// LookupUser retrieves the canonical User for the given username.
|
||||
// Returns domain.ErrUserNotFound when no matching entry exists.
|
||||
// After mapping LDAP attributes the result is run through the canonical
|
||||
// LDAP schema validator; a validation failure is returned as an error.
|
||||
func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.User, error) {
|
||||
conn, err := a.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
a.cfg.userBaseDN(),
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
filter,
|
||||
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
|
||||
nil,
|
||||
)
|
||||
result, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldap: search for user %q: %w", username, err)
|
||||
}
|
||||
if len(result.Entries) == 0 {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
entry := result.Entries[0]
|
||||
user := mapEntryToUser(entry)
|
||||
|
||||
// Run the canonical LDAP schema validator.
|
||||
snap := validator.Snapshot{Users: []domain.User{user}}
|
||||
report := validator.Validate(snap, validator.ModeProvisioning)
|
||||
if !report.Passed {
|
||||
return nil, fmt.Errorf("lldap: validation failed for user %q: %s", username, validationSummary(report))
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to.
|
||||
func (a *LDAPAdapter) LookupGroups(ctx context.Context, userDN string) ([]domain.Group, error) {
|
||||
conn, err := a.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Search for groups that list the user as a member.
|
||||
filter := fmt.Sprintf("(member=%s)", ldap.EscapeFilter(userDN))
|
||||
req := ldap.NewSearchRequest(
|
||||
a.cfg.groupBaseDN(),
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
filter,
|
||||
[]string{"dn", "cn", "description"},
|
||||
nil,
|
||||
)
|
||||
result, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldap: group search for DN %q: %w", userDN, err)
|
||||
}
|
||||
|
||||
groups := make([]domain.Group, 0, len(result.Entries))
|
||||
for _, entry := range result.Entries {
|
||||
groups = append(groups, domain.Group{
|
||||
ID: entry.DN,
|
||||
Name: entry.GetAttributeValue("cn"),
|
||||
Description: entry.GetAttributeValue("description"),
|
||||
})
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// ValidatePassword returns true when the username and password are valid.
|
||||
// It opens a second connection and attempts a user bind. Bind failure (wrong
|
||||
// credentials) returns false, nil. Infrastructure errors return false, err.
|
||||
func (a *LDAPAdapter) ValidatePassword(ctx context.Context, username, password string) (bool, error) {
|
||||
// First resolve the user DN.
|
||||
conn, err := a.dial()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
a.cfg.userBaseDN(),
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
result, err := conn.Search(req)
|
||||
conn.Close()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lldap: DN lookup for user %q: %w", username, err)
|
||||
}
|
||||
if len(result.Entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
userDN := result.Entries[0].DN
|
||||
|
||||
// Attempt a user bind with the provided password using a fresh connection.
|
||||
host := ldapHost(a.cfg.URL)
|
||||
userConn, err := a.dialFn(host)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer userConn.Close()
|
||||
|
||||
if err := userConn.Bind(userDN, password); err != nil {
|
||||
// Distinguish authentication failure from infrastructure error.
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("lldap: user bind for %q: %w", username, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute mapping helpers (LDAP → canonical domain model).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mapEntryToUser converts an LDAP entry to a canonical domain.User.
|
||||
// Attribute mapping per spec:
|
||||
// - uid → Username
|
||||
// - cn → DisplayName (sn as fallback)
|
||||
// - sn → DisplayName fallback if cn is empty
|
||||
// - mail → Email
|
||||
// - memberOf → Groups (DNs parsed to group names)
|
||||
// - dn → ID (stable identifier)
|
||||
func mapEntryToUser(entry *ldap.Entry) domain.User {
|
||||
displayName := entry.GetAttributeValue("cn")
|
||||
if displayName == "" {
|
||||
displayName = entry.GetAttributeValue("sn")
|
||||
}
|
||||
|
||||
memberOfs := entry.GetAttributeValues("memberOf")
|
||||
groups := make([]string, 0, len(memberOfs))
|
||||
for _, dn := range memberOfs {
|
||||
groups = append(groups, groupNameFromDN(dn))
|
||||
}
|
||||
|
||||
return domain.User{
|
||||
ID: entry.DN,
|
||||
Username: entry.GetAttributeValue("uid"),
|
||||
DisplayName: displayName,
|
||||
Email: entry.GetAttributeValue("mail"),
|
||||
Groups: groups,
|
||||
Enabled: true, // LLDAP does not expose a disabled flag in base schema
|
||||
}
|
||||
}
|
||||
|
||||
// groupNameFromDN extracts the cn value from an LDAP DN such as
|
||||
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
|
||||
// If parsing fails the full DN is returned unchanged.
|
||||
func groupNameFromDN(dn string) string {
|
||||
parts := strings.SplitN(dn, ",", 2)
|
||||
if len(parts) == 0 {
|
||||
return dn
|
||||
}
|
||||
kv := strings.SplitN(parts[0], "=", 2)
|
||||
if len(kv) == 2 {
|
||||
return kv[1]
|
||||
}
|
||||
return dn
|
||||
}
|
||||
|
||||
// ldapHost extracts host:port from a URL string; falls back to the raw value.
|
||||
func ldapHost(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return rawURL
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// validationSummary produces a short string summarising all failed rules.
|
||||
func validationSummary(r validator.Report) string {
|
||||
var msgs []string
|
||||
for _, rule := range r.Structural {
|
||||
if !rule.Passed {
|
||||
msgs = append(msgs, rule.Message)
|
||||
}
|
||||
}
|
||||
for _, rule := range r.Semantic {
|
||||
if !rule.Passed {
|
||||
msgs = append(msgs, rule.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
341
src/internal/adapters/lldap/adapter_test.go
Normal file
341
src/internal/adapters/lldap/adapter_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package lldap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"keycape/internal/adapters/lldap"
|
||||
"keycape/internal/domain"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock LDAP connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mockConn implements lldap.LDAPConn for test injection.
|
||||
type mockConn struct {
|
||||
bindFn func(username, password string) error
|
||||
searchFn func(req *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (m *mockConn) Bind(username, password string) error {
|
||||
if m.bindFn != nil {
|
||||
return m.bindFn(username, password)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
if m.searchFn != nil {
|
||||
return m.searchFn(req)
|
||||
}
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
|
||||
func (m *mockConn) Close() error {
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// testConfig returns a minimal Config suitable for tests.
|
||||
func testConfig() lldap.Config {
|
||||
return lldap.Config{
|
||||
URL: "ldap://lldap:389",
|
||||
BindDN: "cn=admin,dc=netkingdom,dc=local",
|
||||
BindPW: "secret",
|
||||
BaseDN: "dc=netkingdom,dc=local",
|
||||
}
|
||||
}
|
||||
|
||||
// singleEntryResult builds a SearchResult with one entry for LookupUser tests.
|
||||
func singleEntryResult(dn, uid, cn, sn, mail string, memberOfs []string) *ldap.SearchResult {
|
||||
attrs := []*ldap.EntryAttribute{
|
||||
{Name: "uid", Values: []string{uid}},
|
||||
{Name: "cn", Values: []string{cn}},
|
||||
{Name: "sn", Values: []string{sn}},
|
||||
{Name: "mail", Values: []string{mail}},
|
||||
}
|
||||
if len(memberOfs) > 0 {
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: memberOfs})
|
||||
}
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{
|
||||
{DN: dn, Attributes: attrs},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// makeAdapter returns an LDAPAdapter using the exported NewForTest constructor.
|
||||
// We use the package-level helper exported for testing.
|
||||
func makeAdapter(cfg lldap.Config, conn lldap.LDAPConn) *lldap.LDAPAdapter {
|
||||
return lldap.NewForTest(cfg, func(_ string) (lldap.LDAPConn, error) {
|
||||
return conn, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LookupUser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLookupUser_Success(t *testing.T) {
|
||||
dn := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return singleEntryResult(
|
||||
dn, "alice", "Alice Liddell", "Liddell", "alice@example.com",
|
||||
[]string{"cn=admins,ou=groups,dc=netkingdom,dc=local"},
|
||||
), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
user, err := adapter.LookupUser(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if user.Username != "alice" {
|
||||
t.Errorf("Username: want %q, got %q", "alice", user.Username)
|
||||
}
|
||||
if user.DisplayName != "Alice Liddell" {
|
||||
t.Errorf("DisplayName: want %q, got %q", "Alice Liddell", user.DisplayName)
|
||||
}
|
||||
if user.Email != "alice@example.com" {
|
||||
t.Errorf("Email: want %q, got %q", "alice@example.com", user.Email)
|
||||
}
|
||||
if user.ID != dn {
|
||||
t.Errorf("ID: want %q, got %q", dn, user.ID)
|
||||
}
|
||||
if len(user.Groups) != 1 || user.Groups[0] != "admins" {
|
||||
t.Errorf("Groups: want [admins], got %v", user.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUser_DisplayName_FallsBackToSN(t *testing.T) {
|
||||
dn := "uid=bob,ou=users,dc=netkingdom,dc=local"
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return singleEntryResult(dn, "bob", "", "Builder", "bob@example.com", nil), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
user, err := adapter.LookupUser(context.Background(), "bob")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if user.DisplayName != "Builder" {
|
||||
t.Errorf("DisplayName fallback: want %q, got %q", "Builder", user.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUser_NotFound(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil // zero entries
|
||||
},
|
||||
}
|
||||
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
_, err := adapter.LookupUser(context.Background(), "ghost")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, domain.ErrUserNotFound) {
|
||||
t.Errorf("expected domain.ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUser_ValidationFailure(t *testing.T) {
|
||||
// Return an entry with an empty DisplayName and empty sn — will fail validator.
|
||||
dn := "uid=broken,ou=users,dc=netkingdom,dc=local"
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
attrs := []*ldap.EntryAttribute{
|
||||
{Name: "uid", Values: []string{"broken"}},
|
||||
{Name: "cn", Values: []string{""}},
|
||||
{Name: "sn", Values: []string{""}},
|
||||
{Name: "mail", Values: []string{"broken@example.com"}},
|
||||
}
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
_, err := adapter.LookupUser(context.Background(), "broken")
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LookupGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLookupGroups_Success(t *testing.T) {
|
||||
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{
|
||||
{
|
||||
DN: "cn=admins,ou=groups,dc=netkingdom,dc=local",
|
||||
Attributes: []*ldap.EntryAttribute{
|
||||
{Name: "cn", Values: []string{"admins"}},
|
||||
{Name: "description", Values: []string{"Admins group"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
groups, err := adapter.LookupGroups(context.Background(), userDN)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("want 1 group, got %d", len(groups))
|
||||
}
|
||||
if groups[0].Name != "admins" {
|
||||
t.Errorf("Group name: want %q, got %q", "admins", groups[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupGroups_Empty(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil
|
||||
},
|
||||
}
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
groups, err := adapter.LookupGroups(context.Background(), "uid=nobody,ou=users,dc=test,dc=local")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(groups) != 0 {
|
||||
t.Errorf("expected 0 groups, got %d", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ValidatePassword
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidatePassword_Success(t *testing.T) {
|
||||
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||
|
||||
callCount := 0
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}}
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}},
|
||||
}, nil
|
||||
},
|
||||
bindFn: func(username, password string) error {
|
||||
callCount++
|
||||
// First call: service bind (BindDN); second call: user bind.
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Provide two connections: one for the DN lookup and one for the user bind.
|
||||
connIdx := 0
|
||||
conns := []*mockConn{conn, {bindFn: func(u, p string) error { return nil }}}
|
||||
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
|
||||
c := conns[connIdx]
|
||||
connIdx++
|
||||
return c, nil
|
||||
})
|
||||
|
||||
ok, err := adapter.ValidatePassword(context.Background(), "alice", "correct")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected ValidatePassword to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword_WrongPassword(t *testing.T) {
|
||||
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||
|
||||
searchConn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}}
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
userConn := &mockConn{
|
||||
bindFn: func(username, password string) error {
|
||||
return ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials"))
|
||||
},
|
||||
}
|
||||
|
||||
connIdx := 0
|
||||
conns := []lldap.LDAPConn{searchConn, userConn}
|
||||
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
|
||||
c := conns[connIdx]
|
||||
connIdx++
|
||||
return c, nil
|
||||
})
|
||||
|
||||
ok, err := adapter.ValidatePassword(context.Background(), "alice", "wrong")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ValidatePassword to return false for wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword_BindFailure(t *testing.T) {
|
||||
// Service bind fails — infrastructure error.
|
||||
conn := &mockConn{
|
||||
bindFn: func(username, password string) error {
|
||||
return errors.New("connection refused")
|
||||
},
|
||||
}
|
||||
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
|
||||
return conn, nil
|
||||
})
|
||||
|
||||
ok, err := adapter.ValidatePassword(context.Background(), "alice", "pass")
|
||||
if err == nil {
|
||||
t.Fatal("expected infrastructure error, got nil")
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected false on bind failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword_UserNotFound(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil // no entries
|
||||
},
|
||||
}
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
|
||||
ok, err := adapter.ValidatePassword(context.Background(), "ghost", "pass")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected false for non-existent user")
|
||||
}
|
||||
}
|
||||
55
src/internal/adapters/lldap/config.go
Normal file
55
src/internal/adapters/lldap/config.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Package lldap implements the UserRepository adapter for LLDAP (Lightweight LDAP).
|
||||
// No LDAP types are exposed beyond this package — the domain and server layers
|
||||
// interact exclusively through the domain.UserRepository interface.
|
||||
package lldap
|
||||
|
||||
// Config holds all connection parameters for the LLDAP adapter.
|
||||
type Config struct {
|
||||
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636".
|
||||
URL string
|
||||
|
||||
// BindDN is the distinguished name used for the service account bind,
|
||||
// e.g. "cn=admin,dc=netkingdom,dc=local".
|
||||
BindDN string
|
||||
|
||||
// BindPW is the service account password.
|
||||
BindPW string
|
||||
|
||||
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
|
||||
BaseDN string
|
||||
|
||||
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
|
||||
UserOU string
|
||||
|
||||
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
|
||||
GroupOU string
|
||||
|
||||
// TLSSkipVerify disables TLS certificate verification. For development only.
|
||||
TLSSkipVerify bool
|
||||
}
|
||||
|
||||
// userOU returns the effective UserOU, falling back to the default.
|
||||
func (c Config) userOU() string {
|
||||
if c.UserOU != "" {
|
||||
return c.UserOU
|
||||
}
|
||||
return "ou=users"
|
||||
}
|
||||
|
||||
// groupOU returns the effective GroupOU, falling back to the default.
|
||||
func (c Config) groupOU() string {
|
||||
if c.GroupOU != "" {
|
||||
return c.GroupOU
|
||||
}
|
||||
return "ou=groups"
|
||||
}
|
||||
|
||||
// userBaseDN returns the full DN for the user search base.
|
||||
func (c Config) userBaseDN() string {
|
||||
return c.userOU() + "," + c.BaseDN
|
||||
}
|
||||
|
||||
// groupBaseDN returns the full DN for the group search base.
|
||||
func (c Config) groupBaseDN() string {
|
||||
return c.groupOU() + "," + c.BaseDN
|
||||
}
|
||||
Reference in New Issue
Block a user