feat(PROJ-21/23): Pro-Tenant Xapian-Index + Tenant-LDAP Backend

PROJ-21 Phase 4:
- internal/index/tenant_manager.go: TenantIndexManager mit lazy-loading Pool
- internal/index/tenant_worker.go: TenantIndexWorker leitet Submit an richtigen Index
- Jeder Mandant bekommt eigenes Xapian-Verzeichnis (tenant-<id>/)
- handleSearch nutzt direkt Tenant-Index statt nachgelagertem Post-Filter
- runBackfill re-indexiert pro Mandant beim Start

PROJ-23 / PROJ-16 Phase B:
- internal/ldapconfig/tenant_store.go: TenantStore mit AES-256-GCM für tenant_ldap
- internal/api/ldap_tenants.go: 8 neue Handler (GET/PUT/DELETE/test für
  /api/tenant/ldap und /api/admin/tenants/{id}/ldap)
- internal/auth/auth.go: Login-Fallback prüft tenant_ldap nach globalem LDAP
  (Domain-Extraktion → tenant_ldap config → UpsertLDAPUser mit tenant_id)
- internal/api/server.go: SetTenantLDAP(), neue Routen registriert
- internal/tenantstore/store.go: GetByDomain() Interface für auth-Package
- cmd/archivmail/main.go: TenantLDAPStore + TenantIndexManager verdrahtet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 00:18:35 +01:00
parent 46d7bfe608
commit 78d83d3e98
9 changed files with 977 additions and 24 deletions
+79 -4
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -24,11 +25,20 @@ type Session struct {
TenantSlug string
}
// TenantDomainLookup is the interface used by Manager to resolve an email domain
// to a tenant ID. Implemented by tenantstore.Store.
type TenantDomainLookup interface {
// GetTenantIDByDomain returns the tenant_id for a given domain, or nil if not found.
GetTenantIDByDomain(ctx context.Context, domain string) (*int64, error)
}
// Manager handles login, token issuance, validation, and logout.
type Manager struct {
store *userstore.Store
ldapStore *ldapcfg.Store
jwtSecret []byte
store *userstore.Store
ldapStore *ldapcfg.Store
jwtSecret []byte
tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config
tenantLookup TenantDomainLookup // PROJ-23: domain -> tenant_id resolution
}
// New creates a new auth Manager.
@@ -41,6 +51,13 @@ func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Ma
}
}
// SetTenantLDAP wires the per-tenant LDAP config store and tenant domain lookup
// into the auth manager. Both may be nil to disable per-tenant LDAP.
func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLookup TenantDomainLookup) {
m.tenantLdapStore = tenantLdapStore
m.tenantLookup = tenantLookup
}
// Login verifies credentials and returns a signed JWT token.
// It first attempts a local password check. If that fails and LDAP is
// configured and enabled, it falls back to LDAP authentication.
@@ -51,7 +68,7 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
return m.issueToken(user)
}
// 2. LDAP fallback when the store is wired and the config is enabled.
// 2. Global LDAP fallback when the store is wired and the config is enabled.
if m.ldapStore != nil {
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
if ldapErr == nil && cfg != nil && cfg.Enabled {
@@ -93,6 +110,54 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
}
}
// 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 domain := extractDomain(username); domain != "" {
ctx := context.Background()
tenantID, lookupErr := m.tenantLookup.GetTenantIDByDomain(ctx, domain)
if lookupErr == nil && tenantID != nil {
tcfg, tErr := m.tenantLdapStore.GetWithPassword(ctx, *tenantID)
if tErr == nil && tcfg != nil && tcfg.Enabled {
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
URL: tcfg.URL,
BindDN: tcfg.BindDN,
BindPassword: tcfg.BindPassword,
BaseDN: tcfg.BaseDN,
UserFilter: tcfg.UserFilter,
TLS: tcfg.TLS,
TLSSkipVerify: tcfg.TLSSkipVerify,
}, username, password)
if authErr == nil {
role := tcfg.DefaultRole
if role == "" {
role = userstore.RoleUser
}
memberOf := attrs["memberOf"]
if memberOf != "" {
for _, gm := range tcfg.GroupMappings {
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
role = gm.Role
break
}
}
}
email := attrs["mail"]
if email == "" {
email = username
}
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
if upsertErr == nil {
return m.issueToken(ldapUser)
}
}
}
}
}
}
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
@@ -281,6 +346,16 @@ func trimSpace(s string) string {
return s[start:end]
}
// extractDomain returns the domain part from a username containing '@'.
// Returns empty string if no '@' is found.
func extractDomain(username string) string {
idx := strings.LastIndex(username, "@")
if idx < 0 || idx == len(username)-1 {
return ""
}
return strings.ToLower(username[idx+1:])
}
// generateJTI returns a cryptographically random identifier for a JWT.
func generateJTI() string {
b := make([]byte, 16)