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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ AD_USER_FILTER=(objectClass=user)
AD_GROUP_FILTER=(objectClass=group)
AD_SEARCH_FILTER=(objectClass=*)

# Optional: Restrict searches to a specific OU (falls back to AD_BASE_DN if empty)
# AD_SEARCH_BASE_DN=OU=Corporate,dc=example,dc=com

# Optional: Semicolon-separated list of DN path fragments (OUs, CN containers) to exclude from all search results
# AD_EXCLUDED_OBJECTS=CN=Builtin,dc=example,dc=com;OU=Disabled Users,dc=example,dc=com

# Optional: Semicolon-separated list of group CNs or DNs to exclude from group results
# AD_EXCLUDED_GROUPS=Domain Admins;Schema Admins;Enterprise Admins

# TLS/HTTPS Configuration for the web server
# Set TLS_ENABLED=false to run HTTP instead of HTTPS
TLS_ENABLED=true
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,34 @@ Response:
| AD_USER_FILTER | LDAP filter for users | (objectClass=user) |
| AD_GROUP_FILTER | LDAP filter for groups | (objectClass=group) |
| AD_SEARCH_FILTER | LDAP filter for general searches | (objectClass=*) |
| AD_SEARCH_BASE_DN | Restrict searches to a specific OU (falls back to AD_BASE_DN if empty) | |
| AD_EXCLUDED_OBJECTS | Semicolon-separated list of DN path fragments (OUs, CN containers) to exclude from results | |
| AD_EXCLUDED_GROUPS | Semicolon-separated list of group CNs or DNs to exclude from results | |
| TLS_ENABLED | Enable HTTPS | true |
| TLS_CERT_FILE | Path to TLS certificate | certs/server.crt |
| TLS_KEY_FILE | Path to TLS private key | certs/server.key |

### Search Scope and Exclusions

To restrict API searches to a specific OU instead of the entire domain:

```bash
AD_BASE_DN=DC=example,DC=com # Used for authentication (broad)
AD_SEARCH_BASE_DN=OU=Corporate,DC=example,DC=com # Used for listing/searching (narrow)
```

To hide specific containers from all search results (users, groups, and generic search):

```bash
AD_EXCLUDED_OBJECTS=CN=Builtin,DC=example,DC=com;OU=Disabled Users,DC=example,DC=com
```

To hide specific groups from group listings and lookups:

```bash
AD_EXCLUDED_GROUPS=Domain Admins;Schema Admins;Enterprise Admins
```

### LDAPS Configuration

To use LDAPS (LDAP over SSL):
Expand Down
54 changes: 45 additions & 9 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ type ADConfig struct {
UserFilter string
GroupFilter string
SearchFilter string
// SearchBaseDN restricts searches to a specific OU (e.g. "OU=Corp,DC=example,DC=com").
// If empty, BaseDN is used.
SearchBaseDN string
// ExcludedObjects is a list of DN path fragments (OUs, CNs, etc.) whose entries are filtered out of results.
ExcludedObjects []string
// ExcludedGroups is a list of group CNs or DNs that are filtered out of group results.
ExcludedGroups []string
// Optional: Path to CA certificate for LDAPS
CACertPath string
}
Expand Down Expand Up @@ -73,15 +80,18 @@ func Load() (*Config, error) {
IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60) * time.Second,
},
AD: ADConfig{
Server: getEnv("AD_SERVER", ""),
Port: getIntEnv("AD_PORT", 389),
BaseDN: getEnv("AD_BASE_DN", ""),
UseSSL: getBoolEnv("AD_USE_SSL", false),
SkipTLS: getBoolEnv("AD_SKIP_TLS", false),
UserFilter: getEnv("AD_USER_FILTER", "(objectClass=user)"),
GroupFilter: getEnv("AD_GROUP_FILTER", "(objectClass=group)"),
SearchFilter: getEnv("AD_SEARCH_FILTER", "(objectClass=*)"),
CACertPath: getEnv("AD_CA_CERT_PATH", ""),
Server: getEnv("AD_SERVER", ""),
Port: getIntEnv("AD_PORT", 389),
BaseDN: getEnv("AD_BASE_DN", ""),
UseSSL: getBoolEnv("AD_USE_SSL", false),
SkipTLS: getBoolEnv("AD_SKIP_TLS", false),
UserFilter: getEnv("AD_USER_FILTER", "(objectClass=user)"),
GroupFilter: getEnv("AD_GROUP_FILTER", "(objectClass=group)"),
SearchFilter: getEnv("AD_SEARCH_FILTER", "(objectClass=*)"),
SearchBaseDN: getEnv("AD_SEARCH_BASE_DN", ""),
ExcludedObjects: getDNSliceEnv("AD_EXCLUDED_OBJECTS", nil),
ExcludedGroups: getDNSliceEnv("AD_EXCLUDED_GROUPS", nil),
CACertPath: getEnv("AD_CA_CERT_PATH", ""),
},
TLS: TLSConfig{
Enabled: getBoolEnv("TLS_ENABLED", true),
Expand Down Expand Up @@ -120,6 +130,14 @@ func (c *Config) Validate() error {
return nil
}

// GetSearchBaseDN returns SearchBaseDN if set, otherwise falls back to BaseDN.
func (c *ADConfig) GetSearchBaseDN() string {
if c.SearchBaseDN != "" {
return c.SearchBaseDN
}
return c.BaseDN
}

// GetLDAPURL returns the LDAP connection URL
func (c *Config) GetLDAPURL() string {
protocol := "ldap"
Expand Down Expand Up @@ -179,6 +197,24 @@ func getSliceEnv(key string, defaultValue []string) []string {
return defaultValue
}

// getDNSliceEnv gets a semicolon-separated slice from an environment variable.
// Semicolons are used instead of commas because DN values contain commas
// (e.g. "OU=Disabled,DC=example,DC=com;OU=Service Accounts,DC=example,DC=com").
func getDNSliceEnv(key string, defaultValue []string) []string {
if value := os.Getenv(key); value != "" {
var result []string
for _, item := range splitAndTrim(value, ";") {
if item != "" {
result = append(result, item)
}
}
if len(result) > 0 {
return result
}
}
return defaultValue
}

// splitAndTrim splits a string by delimiter and trims spaces
func splitAndTrim(s string, delimiter string) []string {
var result []string
Expand Down
58 changes: 50 additions & 8 deletions handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func (h *Handler) GetAllGroups(w http.ResponseWriter, r *http.Request) {
// Get optional baseDN from query params
baseDN := r.URL.Query().Get("baseDN")
if baseDN == "" {
baseDN = h.config.AD.BaseDN
baseDN = h.config.AD.GetSearchBaseDN()
}

// Get optional filter from query params
Expand Down Expand Up @@ -273,9 +273,13 @@ func (h *Handler) GetAllGroups(w http.ResponseWriter, r *http.Request) {

groups := make([]*models.Group, 0, len(sr.Entries))
for _, entry := range sr.Entries {
cn := entry.GetAttributeValue("cn")
if h.isExcludedDN(entry.DN) || h.isExcludedGroup(cn, entry.DN) {
continue
}
group := &models.Group{
DN: entry.DN,
CN: entry.GetAttributeValue("cn"),
CN: cn,
SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
Description: entry.GetAttributeValue("description"),
GroupType: entry.GetAttributeValue("groupType"),
Expand Down Expand Up @@ -573,7 +577,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {

// Use defaults if not provided
if baseDN == "" {
baseDN = h.config.AD.BaseDN
baseDN = h.config.AD.GetSearchBaseDN()
}
if filter == "" {
filter = h.config.AD.SearchFilter
Expand Down Expand Up @@ -602,9 +606,12 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
return
}

// Convert entries to response format
// Convert entries to response format, filtering out excluded OUs
entries := make([]*models.SearchEntry, 0, len(sr.Entries))
for _, entry := range sr.Entries {
if h.isExcludedDN(entry.DN) {
continue
}
attrs := make(map[string][]string)
for _, attr := range entry.Attributes {
attrs[attr.Name] = attr.Values
Expand Down Expand Up @@ -650,10 +657,32 @@ func (h *Handler) SessionInfo(w http.ResponseWriter, r *http.Request) {

// Helper functions

// isExcludedDN checks whether a DN contains any of the excluded object paths.
func (h *Handler) isExcludedDN(dn string) bool {
dnLower := strings.ToLower(dn)
for _, excluded := range h.config.AD.ExcludedObjects {
if strings.Contains(dnLower, strings.ToLower(excluded)) {
return true
}
}
return false
}

// isExcludedGroup checks whether a group CN or DN matches the excluded groups list.
func (h *Handler) isExcludedGroup(cn, dn string) bool {
for _, excluded := range h.config.AD.ExcludedGroups {
excludedLower := strings.ToLower(excluded)
if strings.ToLower(cn) == excludedLower || strings.EqualFold(dn, excluded) {
return true
}
}
return false
}

// findUser searches for a user and returns their details
func (h *Handler) findUser(conn *ldap.Conn, username string) (*models.User, error) {
searchReq := ldap.NewSearchRequest(
h.config.AD.BaseDN,
h.config.AD.GetSearchBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, 0, false,
Expand All @@ -675,13 +704,17 @@ func (h *Handler) findUser(conn *ldap.Conn, username string) (*models.User, erro
return nil, fmt.Errorf("user not found")
}

if h.isExcludedDN(sr.Entries[0].DN) {
return nil, fmt.Errorf("user not found")
}

return entryToUser(sr.Entries[0]), nil
}

// findUserDN finds the DN for a user
func (h *Handler) findUserDN(conn *ldap.Conn, username string) (string, error) {
searchReq := ldap.NewSearchRequest(
h.config.AD.BaseDN,
h.config.AD.GetSearchBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, 0, false,
Expand All @@ -703,13 +736,17 @@ func (h *Handler) findUserDN(conn *ldap.Conn, username string) (string, error) {
return "", fmt.Errorf("user not found")
}

if h.isExcludedDN(sr.Entries[0].DN) {
return "", fmt.Errorf("user not found")
}

return sr.Entries[0].DN, nil
}

// findGroupDN finds the DN for a group
func (h *Handler) findGroupDN(conn *ldap.Conn, groupName string) (string, error) {
searchReq := ldap.NewSearchRequest(
h.config.AD.BaseDN,
h.config.AD.GetSearchBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, 0, false,
Expand All @@ -730,7 +767,12 @@ func (h *Handler) findGroupDN(conn *ldap.Conn, groupName string) (string, error)
return "", fmt.Errorf("group not found")
}

return sr.Entries[0].DN, nil
dn := sr.Entries[0].DN
if h.isExcludedDN(dn) || h.isExcludedGroup(groupName, dn) {
return "", fmt.Errorf("group not found")
}

return dn, nil
}

// getUserByDN retrieves a user by their DN
Expand Down
14 changes: 12 additions & 2 deletions session/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -237,7 +238,7 @@ func (m *Manager) createLDAPConnection() (*ldap.Conn, error) {
func (m *Manager) findUserDN(conn *ldap.Conn, username string) (string, error) {
// Search for the user
searchRequest := ldap.NewSearchRequest(
m.cfg.AD.BaseDN,
m.cfg.AD.GetSearchBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, 0, false,
Expand All @@ -256,7 +257,16 @@ func (m *Manager) findUserDN(conn *ldap.Conn, username string) (string, error) {
return "", fmt.Errorf("user not found")
}

return sr.Entries[0].DN, nil
// Check if the user is in an excluded object (OU, CN container, etc.)
dn := sr.Entries[0].DN
dnLower := strings.ToLower(dn)
for _, excluded := range m.cfg.AD.ExcludedObjects {
if strings.Contains(dnLower, strings.ToLower(excluded)) {
return "", fmt.Errorf("user not found")
}
}

return dn, nil
}

// cleanupExpiredSessions periodically removes expired sessions
Expand Down