Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,30 @@ These settings must be configured for LDAP Manager to function:

### LDAP Connection Settings

| Setting | Environment Variable | CLI Flag | Description | Example |
| ----------------- | ------------------------ | --------------------- | -------------------------------------- | ----------------------------- |
| LDAP Server | `LDAP_SERVER` | `--ldap-server` | LDAP server URI with protocol and port | `ldaps://dc1.example.com:636` |
| Base DN | `LDAP_BASE_DN` | `--base-dn` | Base Distinguished Name for searches | `DC=example,DC=com` |
| Readonly User | `LDAP_READONLY_USER` | `--readonly-user` | Service account username | `readonly` |
| Readonly Password | `LDAP_READONLY_PASSWORD` | `--readonly-password` | Service account password | `secure_password123` |
| Setting | Environment Variable | CLI Flag | Description | Example | Required |
| ----------------- | ------------------------ | --------------------- | -------------------------------------- | ----------------------------- | -------- |
| LDAP Server | `LDAP_SERVER` | `--ldap-server` | LDAP server URI with protocol and port | `ldaps://dc1.example.com:636` | Yes |
| Base DN | `LDAP_BASE_DN` | `--base-dn` | Base Distinguished Name for searches | `DC=example,DC=com` | Yes |
| Readonly User | `LDAP_READONLY_USER` | `--readonly-user` | Service account username | `readonly` | No |
| Readonly Password | `LDAP_READONLY_PASSWORD` | `--readonly-password` | Service account password | `secure_password123` | No |

### Operating Modes

LDAP Manager supports two operating modes based on whether a service account is configured:

**Service Account Mode** (both `LDAP_READONLY_USER` and `LDAP_READONLY_PASSWORD` set):

- Background cache refreshes LDAP data every 30 seconds
- Health checks verify LDAP connectivity
- Service account used for initial user lookup during authentication

**Per-User Credentials Mode** (no service account configured):

- Each request uses the logged-in user's own LDAP credentials
- No background cache (data fetched fresh per request)
- Health checks report simplified status
- Requires Active Directory with UPN-based authentication (`user@domain`)
- Users must have sufficient LDAP permissions to read directory data

### LDAP Server URI Format

Expand Down Expand Up @@ -172,6 +190,24 @@ SESSION_PATH=/data/sessions.bbolt
SESSION_DURATION=30m
```

### Per-User Credentials (No Service Account)

For Active Directory environments where each user authenticates with their own credentials:

```bash
# .env.local
LDAP_SERVER=ldaps://dc1.ad.example.com:636
LDAP_BASE_DN=DC=ad,DC=example,DC=com
LDAP_IS_AD=true

# No LDAP_READONLY_USER or LDAP_READONLY_PASSWORD
# Users authenticate with their own AD credentials via UPN (user@domain)

LOG_LEVEL=info
PERSIST_SESSIONS=true
SESSION_DURATION=30m
```

### Development Environment

For local development and testing:
Expand Down
9 changes: 6 additions & 3 deletions internal/ldap_cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,16 @@ func (c *Cache[T]) update(fn func(*T)) {
c.buildIndexes()
}

// Get returns a copy of all cached items.
// This operation is read-locked to allow concurrent access from multiple readers.
// Get returns a snapshot copy of all cached items.
// The returned slice is safe to iterate without holding any lock.
func (c *Cache[T]) Get() []T {
c.m.RLock()
defer c.m.RUnlock()

return c.items
result := make([]T, len(c.items))
copy(result, c.items)

return result
}

// Find searches the cache for the first item matching the provided predicate.
Expand Down
156 changes: 92 additions & 64 deletions internal/ldap_cache/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"slices"
"sync"
"sync/atomic"
"time"

ldap "github.com/netresearch/simple-ldap-go"
Expand Down Expand Up @@ -40,9 +41,9 @@ type Manager struct {

client LDAPClient // LDAP client for directory operations
metrics *Metrics // Performance metrics and health monitoring
refreshInterval time.Duration // Configurable refresh interval (default 30s)
warmupComplete bool // Tracks if initial cache warming is complete
retryConfig retry.Config // Retry configuration for LDAP operations
refreshInterval time.Duration // Configurable refresh interval (default 30s)
warmupComplete atomic.Bool // Tracks if initial cache warming is complete (concurrent-safe)
retryConfig retry.Config // Retry configuration for LDAP operations

Users Cache[ldap.User] // Cached user entries with O(1) indexed lookups
Groups Cache[ldap.Group] // Cached group entries with O(1) indexed lookups
Expand Down Expand Up @@ -95,7 +96,6 @@ func NewWithConfig(client LDAPClient, refreshInterval time.Duration) *Manager {
client: client,
metrics: metrics,
refreshInterval: refreshInterval,
warmupComplete: false,
retryConfig: retry.LDAPConfig(),
Users: NewCachedWithMetrics[ldap.User](metrics),
Groups: NewCachedWithMetrics[ldap.Group](metrics),
Expand Down Expand Up @@ -209,7 +209,7 @@ func (m *Manager) WarmupCache() {
duration := time.Since(startTime)

if !hasErrors {
m.warmupComplete = true
m.warmupComplete.Store(true)
m.metrics.RecordRefreshComplete(startTime, m.Users.Count(), m.Groups.Count(), m.Computers.Count())
log.Info().
Int("total_entities", totalEntities).
Expand All @@ -226,7 +226,7 @@ func (m *Manager) WarmupCache() {
// IsWarmedUp returns true if the initial cache warming process has completed successfully.
// Used to determine if the cache is ready to serve requests optimally.
func (m *Manager) IsWarmedUp() bool {
return m.warmupComplete
return m.warmupComplete.Load()
}

// RefreshUsers fetches all users from LDAP and updates the user cache.
Expand Down Expand Up @@ -414,22 +414,7 @@ func (m *Manager) FindComputerBySAMAccountName(samAccountName string) (*ldap.Com
// which works correctly even when OpenLDAP's memberOf overlay is not enabled.
// Returns a complete user object with expanded group information.
func (m *Manager) PopulateGroupsForUser(user *ldap.User) *FullLDAPUser {
full := &FullLDAPUser{
User: *user,
Groups: make([]ldap.Group, 0),
}

userDN := user.DN()

// Iterate through all groups and check if user is a member
// This approach works regardless of whether memberOf overlay is enabled
for _, group := range m.Groups.Get() {
if slices.Contains(group.Members, userDN) {
full.Groups = append(full.Groups, group)
}
}

return full
return PopulateGroupsForUserFromData(user, m.Groups.Get())
}

// PopulateUsersForGroup creates a FullLDAPGroup with populated member list.
Expand All @@ -438,32 +423,7 @@ func (m *Manager) PopulateGroupsForUser(user *ldap.User) *FullLDAPUser {
// When showDisabled is false, filters out disabled users from membership.
// Returns a complete group object with expanded member and parent group information.
func (m *Manager) PopulateUsersForGroup(group *ldap.Group, showDisabled bool) *FullLDAPGroup {
full := &FullLDAPGroup{
Group: *group,
Members: make([]ldap.User, 0),
ParentGroups: make([]ldap.Group, 0),
}

for _, userDN := range group.Members {
user, err := m.FindUserByDN(userDN)
if err == nil {
if !showDisabled && !user.Enabled {
continue
}

full.Members = append(full.Members, *user)
}
}

// Resolve parent groups from MemberOf
for _, parentDN := range group.MemberOf {
parentGroup, err := m.FindGroupByDN(parentDN)
if err == nil {
full.ParentGroups = append(full.ParentGroups, *parentGroup)
}
}

return full
return PopulateUsersForGroupFromData(group, m.Users.Get(), m.Groups.Get(), showDisabled)
}

// PopulateGroupsForComputer creates a FullLDAPComputer with populated group memberships.
Expand All @@ -472,22 +432,7 @@ func (m *Manager) PopulateUsersForGroup(group *ldap.Group, showDisabled bool) *F
// which works correctly even when OpenLDAP's memberOf overlay is not enabled.
// Returns a complete computer object with expanded group information.
func (m *Manager) PopulateGroupsForComputer(computer *ldap.Computer) *FullLDAPComputer {
full := &FullLDAPComputer{
Computer: *computer,
Groups: make([]ldap.Group, 0),
}

computerDN := computer.DN()

// Iterate through all groups and check if computer is a member
// This approach works regardless of whether memberOf overlay is enabled
for _, group := range m.Groups.Get() {
if slices.Contains(group.Members, computerDN) {
full.Groups = append(full.Groups, group)
}
}

return full
return PopulateGroupsForComputerFromData(computer, m.Groups.Get())
}

// OnAddUserToGroup updates cache when a user is added to a group.
Expand Down Expand Up @@ -540,6 +485,89 @@ func (m *Manager) OnRemoveUserFromGroup(userDN, groupDN string) {
})
}

// PopulateGroupsForUserFromData creates a FullLDAPUser with populated group memberships
// using provided data instead of cache. Works identically to PopulateGroupsForUser
// but operates on explicit slices rather than the cache.
func PopulateGroupsForUserFromData(user *ldap.User, allGroups []ldap.Group) *FullLDAPUser {
full := &FullLDAPUser{
User: *user,
Groups: make([]ldap.Group, 0),
}

userDN := user.DN()

for _, group := range allGroups {
if slices.Contains(group.Members, userDN) {
full.Groups = append(full.Groups, group)
}
}

return full
}

// PopulateUsersForGroupFromData creates a FullLDAPGroup with populated member list
// using provided data instead of cache. Works identically to PopulateUsersForGroup
// but operates on explicit slices rather than the cache.
func PopulateUsersForGroupFromData(
group *ldap.Group, allUsers []ldap.User, allGroups []ldap.Group, showDisabled bool,
) *FullLDAPGroup {
full := &FullLDAPGroup{
Group: *group,
Members: make([]ldap.User, 0),
ParentGroups: make([]ldap.Group, 0),
}

// Build a map for O(1) user lookups by DN
usersByDN := make(map[string]*ldap.User, len(allUsers))
for i := range allUsers {
usersByDN[allUsers[i].DN()] = &allUsers[i]
}

for _, memberDN := range group.Members {
if user, ok := usersByDN[memberDN]; ok {
if !showDisabled && !user.Enabled {
continue
}

full.Members = append(full.Members, *user)
}
}

// Build a map for O(1) group lookups by DN
groupsByDN := make(map[string]*ldap.Group, len(allGroups))
for i := range allGroups {
groupsByDN[allGroups[i].DN()] = &allGroups[i]
}

for _, parentDN := range group.MemberOf {
if parentGroup, ok := groupsByDN[parentDN]; ok {
full.ParentGroups = append(full.ParentGroups, *parentGroup)
}
}

return full
}

// PopulateGroupsForComputerFromData creates a FullLDAPComputer with populated group memberships
// using provided data instead of cache. Works identically to PopulateGroupsForComputer
// but operates on explicit slices rather than the cache.
func PopulateGroupsForComputerFromData(computer *ldap.Computer, allGroups []ldap.Group) *FullLDAPComputer {
full := &FullLDAPComputer{
Computer: *computer,
Groups: make([]ldap.Group, 0),
}

computerDN := computer.DN()

for _, group := range allGroups {
if slices.Contains(group.Members, computerDN) {
full.Groups = append(full.Groups, group)
}
}

return full
}

// GetMetrics returns the current cache metrics for monitoring and observability.
// Provides comprehensive statistics about cache performance, health, and operations.
func (m *Manager) GetMetrics() *Metrics {
Expand Down
9 changes: 3 additions & 6 deletions internal/options/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,12 +264,9 @@ func Parse() (*Opts, error) {
if err := validateRequired("base-dn", fBaseDN); err != nil {
return nil, err
}
if err := validateRequired("readonly-user", fReadonlyUser); err != nil {
return nil, err
}
if err := validateRequired("readonly-password", fReadonlyPassword); err != nil {
return nil, err
}
// readonly-user and readonly-password are optional.
// When not configured, the app uses per-user LDAP credentials
// and the background cache is disabled.

if *fPersistSessions {
if err := validateRequired("session-path", fSessionPath); err != nil {
Expand Down
3 changes: 1 addition & 2 deletions internal/options/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ func TestParse_MissingRequiredFields(t *testing.T) {
}{
{"MissingLDAPServer", "LDAP_SERVER", "ldap-server"},
{"MissingBaseDN", "LDAP_BASE_DN", "base-dn"},
{"MissingReadonlyUser", "LDAP_READONLY_USER", "readonly-user"},
{"MissingReadonlyPassword", "LDAP_READONLY_PASSWORD", "readonly-password"},
// readonly-user and readonly-password are now optional
}

for _, tt := range tests {
Expand Down
Loading
Loading