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:
+79
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user