Files
key-cape/src/internal/adapters/lldap/adapter.go
tegwick d6d41dd84f
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Fix OpenBao OIDC token exchange compatibility
2026-06-01 21:20:54 +02:00

343 lines
10 KiB
Go

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)
// Runtime login should not fail because a live directory entry is missing
// provisioning metadata such as cn/sn. Keep the warning visible for
// diagnostics, but return the resolved user so token issuance can proceed.
snap := validator.Snapshot{Users: []domain.User{user}}
report := validator.Validate(snap, validator.ModeProvisioning)
if !report.Passed {
if user.LDAPAttributes == nil {
user.LDAPAttributes = make(map[string]string)
}
user.LDAPAttributes["_validation_warning"] = 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
}
// ListUsers returns all user records from the LLDAP directory.
// It performs an LDAP search with filter (objectClass=inetOrgPerson) to list every user,
// then validates each against the canonical LDAP schema.
func (a *LDAPAdapter) ListUsers(ctx context.Context) ([]domain.User, error) {
conn, err := a.dial()
if err != nil {
return nil, err
}
defer conn.Close()
req := ldap.NewSearchRequest(
a.cfg.userBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
"(objectClass=inetOrgPerson)",
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
nil,
)
result, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("lldap: list users search: %w", err)
}
users := make([]domain.User, 0, len(result.Entries))
for _, entry := range result.Entries {
user := mapEntryToUser(entry)
snap := validator.Snapshot{Users: []domain.User{user}}
report := validator.Validate(snap, validator.ModeProvisioning)
if !report.Passed {
// Non-fatal: return the user with a warning embedded in LDAPAttributes.
if user.LDAPAttributes == nil {
user.LDAPAttributes = make(map[string]string)
}
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
}
users = append(users, user)
}
return users, 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, "; ")
}