fix(PROJ-23): Privilege Escalation in Tenant-LDAP + Login-Reihenfolge
- BUG-1 (P0): domain_admin kann keine Rollen > auditor in default_role/ group_mappings setzen — serverseitige Allowlist-Prüfung in handleSaveTenantLDAP (user/auditor) und handleAdminSaveTenantLDAP (user/auditor/domain_admin) - WARN-1: Login-Fallback-Reihenfolge korrigiert — tenant_ldap wird jetzt VOR globalem ldap_config geprüft (Spec: tenant > global > local) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -447,6 +447,20 @@ func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
cfg.TenantID = *sess.TenantID
|
cfg.TenantID = *sess.TenantID
|
||||||
|
|
||||||
|
// BUG-1 fix: domain_admin may only assign user/auditor roles — prevent privilege escalation
|
||||||
|
// via LDAP default_role or group_mappings even when bypassing the frontend.
|
||||||
|
allowedForTenantAdmin := map[string]bool{"user": true, "auditor": true}
|
||||||
|
if cfg.DefaultRole != "" && !allowedForTenantAdmin[cfg.DefaultRole] {
|
||||||
|
writeError(w, http.StatusForbidden, "role not allowed for tenant LDAP config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, gm := range cfg.GroupMappings {
|
||||||
|
if !allowedForTenantAdmin[gm.Role] {
|
||||||
|
writeError(w, http.StatusForbidden, "group mapping role not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
|
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
|
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
|
||||||
return
|
return
|
||||||
@@ -575,6 +589,19 @@ func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
cfg.TenantID = id
|
cfg.TenantID = id
|
||||||
|
|
||||||
|
// superadmin may assign up to domain_admin in group mappings — not superadmin itself.
|
||||||
|
allowedForSuperAdmin := map[string]bool{"user": true, "auditor": true, "domain_admin": true}
|
||||||
|
if cfg.DefaultRole != "" && !allowedForSuperAdmin[cfg.DefaultRole] {
|
||||||
|
writeError(w, http.StatusForbidden, "role not allowed for tenant LDAP config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, gm := range cfg.GroupMappings {
|
||||||
|
if !allowedForSuperAdmin[gm.Role] {
|
||||||
|
writeError(w, http.StatusForbidden, "group mapping role not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sess := sessionFromCtx(r.Context())
|
sess := sessionFromCtx(r.Context())
|
||||||
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
|
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
|
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
|
||||||
|
|||||||
+42
-46
@@ -68,50 +68,9 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
|
|||||||
return m.issueToken(user)
|
return m.issueToken(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Global LDAP fallback when the store is wired and the config is enabled.
|
// 2. PROJ-23: Per-tenant LDAP — checked first so tenant config takes priority
|
||||||
if m.ldapStore != nil {
|
// over global LDAP (spec: tenant_ldap > ldap_config > config.yml).
|
||||||
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
|
// Extract domain from username (user@domain.tld), resolve tenant, try auth.
|
||||||
if ldapErr == nil && cfg != nil && cfg.Enabled {
|
|
||||||
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
|
|
||||||
URL: cfg.URL,
|
|
||||||
BindDN: cfg.BindDN,
|
|
||||||
BindPassword: cfg.BindPassword,
|
|
||||||
BaseDN: cfg.BaseDN,
|
|
||||||
UserFilter: cfg.UserFilter,
|
|
||||||
TLS: cfg.TLS,
|
|
||||||
TLSSkipVerify: cfg.TLSSkipVerify,
|
|
||||||
}, username, password)
|
|
||||||
if authErr == nil {
|
|
||||||
// Determine role: check group_mappings first, fall back to default_role.
|
|
||||||
role := cfg.DefaultRole
|
|
||||||
if role == "" {
|
|
||||||
role = userstore.RoleUser
|
|
||||||
}
|
|
||||||
memberOf := attrs["memberOf"]
|
|
||||||
if memberOf != "" {
|
|
||||||
for _, gm := range cfg.GroupMappings {
|
|
||||||
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
|
|
||||||
role = gm.Role
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
email := attrs["mail"]
|
|
||||||
if email == "" {
|
|
||||||
email = username + "@ldap.local"
|
|
||||||
}
|
|
||||||
|
|
||||||
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
|
|
||||||
if upsertErr == nil {
|
|
||||||
return m.issueToken(ldapUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. PROJ-23: Per-tenant LDAP fallback — extract domain from username,
|
|
||||||
// look up the tenant, and try LDAP auth with that tenant's config.
|
|
||||||
if m.tenantLdapStore != nil && m.tenantLookup != nil {
|
if m.tenantLdapStore != nil && m.tenantLookup != nil {
|
||||||
if domain := extractDomain(username); domain != "" {
|
if domain := extractDomain(username); domain != "" {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -142,12 +101,10 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
email := attrs["mail"]
|
email := attrs["mail"]
|
||||||
if email == "" {
|
if email == "" {
|
||||||
email = username
|
email = username
|
||||||
}
|
}
|
||||||
|
|
||||||
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
|
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
|
||||||
if upsertErr == nil {
|
if upsertErr == nil {
|
||||||
return m.issueToken(ldapUser)
|
return m.issueToken(ldapUser)
|
||||||
@@ -158,6 +115,45 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Global LDAP fallback — only reached if no matching tenant LDAP config found.
|
||||||
|
if m.ldapStore != nil {
|
||||||
|
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
|
||||||
|
if ldapErr == nil && cfg != nil && cfg.Enabled {
|
||||||
|
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
|
||||||
|
URL: cfg.URL,
|
||||||
|
BindDN: cfg.BindDN,
|
||||||
|
BindPassword: cfg.BindPassword,
|
||||||
|
BaseDN: cfg.BaseDN,
|
||||||
|
UserFilter: cfg.UserFilter,
|
||||||
|
TLS: cfg.TLS,
|
||||||
|
TLSSkipVerify: cfg.TLSSkipVerify,
|
||||||
|
}, username, password)
|
||||||
|
if authErr == nil {
|
||||||
|
role := cfg.DefaultRole
|
||||||
|
if role == "" {
|
||||||
|
role = userstore.RoleUser
|
||||||
|
}
|
||||||
|
memberOf := attrs["memberOf"]
|
||||||
|
if memberOf != "" {
|
||||||
|
for _, gm := range cfg.GroupMappings {
|
||||||
|
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
|
||||||
|
role = gm.Role
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
email := attrs["mail"]
|
||||||
|
if email == "" {
|
||||||
|
email = username + "@ldap.local"
|
||||||
|
}
|
||||||
|
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
|
||||||
|
if upsertErr == nil {
|
||||||
|
return m.issueToken(ldapUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user