diff --git a/.env.example b/.env.example index 78d9da7..6db2055 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 33f9813..0937db1 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/config/config.go b/config/config.go index 4e6d192..98fba7b 100644 --- a/config/config.go +++ b/config/config.go @@ -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 } @@ -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), @@ -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" @@ -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 diff --git a/handlers/handler.go b/handlers/handler.go index 24f0774..bc3f0d1 100644 --- a/handlers/handler.go +++ b/handlers/handler.go @@ -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 @@ -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"), @@ -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 @@ -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 @@ -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, @@ -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, @@ -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, @@ -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 diff --git a/session/manager.go b/session/manager.go index d08c39f..7dc3b34 100644 --- a/session/manager.go +++ b/session/manager.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "os" + "strings" "sync" "time" @@ -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, @@ -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