refactor: migrate LDAP sync to an actor (#1651)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
Alessandro (Ale) Segala
2026-08-07 09:18:30 -07:00
committed by GitHub
co-authored by Kyle Mendell
parent f8db1d8a86
commit 563c0f93a6
27 changed files with 627 additions and 181 deletions
+130
View File
@@ -0,0 +1,130 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
)
// The LdapSync singleton actor decides when the recurring LDAP synchronization runs.
// SyncActorType is the actor type for the LDAP sync actor
const SyncActorType = "LdapSync"
const (
// alarmSync is the name of the repeating alarm that runs the synchronization
alarmSync = "sync"
// syncInterval is how often the synchronization runs, as the ISO8601 duration the alarm repetition expects
// There's no jitter: the alarm is cluster-wide, so there are no replicas to spread apart
syncInterval = "PT1H"
// Delay the initial sync by 5s
initialSyncDelay = 5 * time.Second
// alarmTimeout bounds the alarm operations performed by the actor
alarmTimeout = 10 * time.Second
)
// syncActor is the cluster-wide singleton that triggers the recurring LDAP synchronization
type syncActor struct {
log *slog.Logger
service *Service
appConfig appconfig.AppConfigResolver
// scheduleDisabled removes the alarm instead of arming it, for environments that drive syncs explicitly
scheduleDisabled bool
client actor.Client[struct{}]
}
// NewSyncActor returns the factory that allocates the LDAP sync actor
func NewSyncActor(service *Service, appConfig appconfig.AppConfigResolver, scheduleDisabled bool) actor.Factory {
return func(actorID string, actorService *actor.Service) actor.Actor {
return &syncActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", SyncActorType),
),
service: service,
appConfig: appConfig,
scheduleDisabled: scheduleDisabled,
// The actor keeps no state of its own: the client is only used to manage the alarm
client: actor.NewActorClient[struct{}](SyncActorType, actorID, actorService),
}
}
}
// Bootstrap implements actor.ActorBootstrapper
// The host drives it on every startup, routed to the single owning host, so it must stay idempotent
func (a *syncActor) Bootstrap(parentCtx context.Context, _ actor.Envelope) error {
ctx, cancel := context.WithTimeout(parentCtx, alarmTimeout)
defer cancel()
// The schedule may have been enabled in a previous run, so make sure a leftover alarm doesn't keep firing
if a.scheduleDisabled {
err := a.client.DeleteAlarm(ctx, alarmSync)
if err != nil && !errors.Is(err, actor.ErrAlarmNotFound) {
return fmt.Errorf("error deleting the LDAP sync alarm: %w", err)
}
return nil
}
// Setting the alarm replaces whatever is registered, which both restores an alarm that was lost and picks up a change to the interval
// It's due right away (with a small delay) so the directory is synchronized as soon as the cluster starts, matching what the pre-actor scheduled job did
err := a.client.SetAlarm(ctx, alarmSync, actor.AlarmProperties{
DueTime: time.Now().Add(initialSyncDelay),
Interval: syncInterval,
})
if err != nil {
return fmt.Errorf("error setting the LDAP sync alarm: %w", err)
}
a.log.DebugContext(parentCtx, "Registered the recurring LDAP sync alarm", slog.String("interval", syncInterval))
return nil
}
// Alarm implements actor.ActorAlarm
func (a *syncActor) Alarm(ctx context.Context, name string, _ actor.Envelope) error {
if name != alarmSync {
return fmt.Errorf("unsupported alarm '%s' for the %s actor", name, SyncActorType)
}
a.sync(ctx)
// A failed sync never surfaces as an error: the framework would retry the occurrence and then delete the alarm once the attempts run out, which would stop the synchronization altogether
// The next occurrence comes around on its own, exactly like the pre-actor scheduled job
return nil
}
// sync runs one synchronization, unless LDAP is disabled
// It logs failures rather than returning them, since the alarm has nowhere useful to send the error
func (a *syncActor) sync(ctx context.Context) {
dbConfig, err := a.appConfig.GetConfig(ctx)
if err != nil {
a.log.ErrorContext(ctx, "Failed to load the app configuration, skipping the LDAP sync", slog.Any("error", err))
return
}
if !dbConfig.LdapEnabled.IsTrue() {
a.log.DebugContext(ctx, "LDAP is disabled, skipping the sync")
return
}
a.log.InfoContext(ctx, "Starting the LDAP sync")
start := time.Now()
err = a.service.SyncAll(ctx, dbConfig)
if err != nil {
a.log.ErrorContext(ctx, "LDAP sync failed, will try again on the next run", slog.Duration("duration", time.Since(start)), slog.Any("error", err))
return
}
a.log.InfoContext(ctx, "LDAP sync completed", slog.Duration("duration", time.Since(start)))
}
+217
View File
@@ -0,0 +1,217 @@
package ldapsync
import (
"context"
"errors"
"testing"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// fakeAppConfigResolver returns a fixed application configuration
type fakeAppConfigResolver struct {
config *appconfig.AppConfigModel
err error
}
func (f fakeAppConfigResolver) GetConfig(_ context.Context) (*appconfig.AppConfigModel, error) {
if f.err != nil {
return nil, f.err
}
return f.config, nil
}
func TestSyncActorBootstrapArmsRecurringAlarm(t *testing.T) {
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
require.NoError(t, act.Bootstrap(t.Context(), nil))
props, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
require.NoError(t, err)
assert.Equal(t, syncInterval, props.Interval)
// The first occurrence is due after the initial delay, so a restart re-syncs the directory shortly after startup
// The tolerance is tight enough to catch the delay being dropped, which would put the due time at "now"
assert.WithinDuration(t, time.Now().Add(initialSyncDelay), props.DueTime, time.Second)
}
func TestSyncActorBootstrapIsIdempotent(t *testing.T) {
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
// Every host bootstraps the singleton, so repeating it must leave a single alarm behind rather than failing
require.NoError(t, act.Bootstrap(t.Context(), nil))
require.NoError(t, act.Bootstrap(t.Context(), nil))
props, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
require.NoError(t, err)
assert.Equal(t, syncInterval, props.Interval)
}
func TestSyncActorBootstrapRemovesAlarmWhenScheduleDisabled(t *testing.T) {
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, true)
// Simulate an alarm left behind by a run where the schedule was still enabled
require.NoError(t, host.SetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync, actor.AlarmProperties{
DueTime: time.Now(),
Interval: syncInterval,
}))
require.NoError(t, act.Bootstrap(t.Context(), nil))
_, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
require.ErrorIs(t, err, actor.ErrAlarmNotFound)
}
func TestSyncActorBootstrapWithScheduleDisabledAndNoAlarm(t *testing.T) {
host, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, true)
// There's nothing to remove, which must not be reported as a failure
require.NoError(t, act.Bootstrap(t.Context(), nil))
_, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
require.ErrorIs(t, err, actor.ErrAlarmNotFound)
}
func TestSyncActorAlarmRunsSync(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
),
ldapSearchResult(),
))
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{config: appCfg}, false)
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
var alice model.User
require.NoError(t, db.First(&alice, "ldap_id = ?", "u-alice").Error)
assert.Equal(t, "alice", alice.Username)
}
func TestSyncActorAlarmSkipsSyncWhenLdapIsDisabled(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
}),
),
ldapSearchResult(),
))
disabledCfg := defaultTestLDAPAppConfig()
disabledCfg.LdapEnabled = "false"
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{config: disabledCfg}, false)
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
var count int64
require.NoError(t, db.Model(&model.User{}).Count(&count).Error)
assert.Zero(t, count)
}
func TestSyncActorAlarmSwallowsSyncFailures(t *testing.T) {
// A failed sync must not surface as an error, or the framework would eventually delete the alarm and stop synchronizing altogether
service, _ := newTestLdapService(t, newFakeLDAPClient(ldapSearchResult(), ldapSearchResult()))
service.clientFactory = func(_ *appconfig.AppConfigModel) (ldapClient, error) {
return nil, errors.New("connection refused")
}
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
}
func TestSyncActorAlarmSwallowsAppConfigFailures(t *testing.T) {
service, _ := newTestLdapService(t, newFakeLDAPClient(ldapSearchResult(), ldapSearchResult()))
_, act := newSyncActorForTest(t, service, fakeAppConfigResolver{err: errors.New("config unavailable")}, false)
require.NoError(t, act.Alarm(t.Context(), alarmSync, nil))
}
func TestSyncActorAlarmRejectsUnknownAlarm(t *testing.T) {
_, act := newSyncActorForTest(t, nil, fakeAppConfigResolver{config: defaultTestLDAPAppConfig()}, false)
err := act.Alarm(t.Context(), "unknown", nil)
require.Error(t, err)
assert.ErrorContains(t, err, "unsupported alarm")
}
func TestSyncActorRegisteredSingletonBootstrapsAndFires(t *testing.T) {
// This exercises the wiring the unit tests above bypass: the host bootstraps the singleton on its own, and the alarm it arms is delivered back to the actor
appCfg := defaultTestLDAPAppConfig()
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
),
ldapSearchResult(),
))
// The host uses the same relaxed alarm intervals the application configures when HA is disabled, since those are what decide how soon the first occurrence is picked up
// Francis only performs an early first fetch when the poll interval is long, so with the default (short) test interval this test would pass even if that behavior regressed
host := testutils.NewActorHostForTest(t,
func(t *testing.T, h *local.Host) {
err := h.RegisterSingletonActor(SyncActorType, NewSyncActor(service, fakeAppConfigResolver{config: appCfg}, false))
require.NoError(t, err)
},
local.WithAlarmsPollInterval(5*time.Minute),
local.WithAlarmsFetchAheadInterval(5*time.Minute),
)
// The host bootstraps singletons in the background once it's ready, so wait for the alarm to show up
require.Eventually(t, func() bool {
_, err := host.GetAlarm(t.Context(), SyncActorType, actor.SingletonActorID, alarmSync)
return err == nil
}, 10*time.Second, 20*time.Millisecond, "the sync alarm was never armed")
// The first occurrence runs shortly after startup rather than waiting out the poll interval, so the deadline here is far below it
require.Eventually(t,
func() bool {
var count int64
require.NoError(t, db.Model(&model.User{}).Where("ldap_id = ?", "u-alice").Count(&count).Error)
return count == 1
},
initialSyncDelay+30*time.Second,
50*time.Millisecond,
"the sync alarm never ran",
)
}
// newSyncActorForTest starts a test actor host and allocates the sync actor against it
// The actor is not registered on the host, so the host never bootstraps or fires it on its own and the test drives it explicitly
func newSyncActorForTest(t *testing.T, service *Service, appConfig appconfig.AppConfigResolver, scheduleDisabled bool) (*local.Host, *syncActor) {
t.Helper()
host := testutils.NewActorHostForTest(t, nil)
act, ok := NewSyncActor(service, appConfig, scheduleDisabled)(actor.SingletonActorID, host.Service()).(*syncActor)
require.True(t, ok)
return host, act
}
+41
View File
@@ -0,0 +1,41 @@
package ldapsync
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
)
type handler struct {
service *Service
appConfig appconfig.AppConfigResolver
}
func newHandler(service *Service, appConfig appconfig.AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
// syncLdap godoc
// @Summary Synchronize LDAP
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/sync-ldap [post]
func (h *handler) syncLdap(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
// The sync runs inline rather than through the actor, so the response reports whether it succeeded
err = h.service.SyncAll(c.Request.Context(), dbConfig)
if err != nil {
return err
}
c.Status(http.StatusNoContent)
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package ldapsync
import (
"context"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
// UserSyncer applies the desired LDAP state to the users in the database
// Every method takes the transaction the sync runs in, since users, groups, and memberships are reconciled atomically
type UserSyncer interface {
CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error)
UpdateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, input dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error)
DisableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error
DeleteUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, tx *gorm.DB, userID string, allowLdapDelete bool) error
// UpdateProfilePicture stores a user's profile picture, which happens after the transaction has been committed since it touches the storage layer
UpdateProfilePicture(ctx context.Context, userID string, file io.ReadSeeker) error
}
// GroupSyncer applies the desired LDAP state to the user groups in the database
type GroupSyncer interface {
CreateInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (model.UserGroup, error)
UpdateInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB) (model.UserGroup, error)
UpdateUsersInternal(ctx context.Context, id string, userIDs []string, tx *gorm.DB) (model.UserGroup, error)
}
type Dependencies struct {
DB *gorm.DB
Actors *local.Host
HTTPClient *http.Client
FileStorage storage.FileStorage
Users UserSyncer
Groups GroupSyncer
AppConfig appconfig.AppConfigResolver
// ScheduleDisabled keeps the recurring sync from being armed
// It's set in the test environment, where syncs are driven explicitly by the end-to-end tests
ScheduleDisabled bool
}
type Module struct {
service *Service
handler *handler
}
func New(deps Dependencies) (*Module, error) {
service := newService(deps)
// Register the actor that drives the recurring sync
// It's a singleton, so the host bootstraps it at startup and the alarm fires once per cluster rather than once per replica
err := deps.Actors.RegisterSingletonActor(SyncActorType, NewSyncActor(service, deps.AppConfig, deps.ScheduleDisabled))
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", SyncActorType, err)
}
return &Module{
service: service,
handler: newHandler(service, deps.AppConfig),
}, nil
}
// RegisterRoutes mounts the manual LDAP synchronization endpoint
// auth guards it, as it's an admin-only operation
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth gin.HandlerFunc) {
apiGroup.POST("/application-configuration/sync-ldap", auth, httpserver.Handle(m.handler.syncLdap))
}
// SyncAll runs a full LDAP synchronization with the provided application configuration
func (m *Module) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error {
return m.service.SyncAll(ctx, dbConfig)
}
+738
View File
@@ -0,0 +1,738 @@
package ldapsync
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"strings"
"time"
"unicode/utf8"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// Service performs the actual LDAP synchronization
// It is deliberately free of any actor concern: the sync actor only decides when a sync runs, while the reconciliation logic lives here and is called directly by the manual "sync now" endpoint too
type Service struct {
db *gorm.DB
httpClient *http.Client
users UserSyncer
groups GroupSyncer
fileStorage storage.FileStorage
clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error)
}
type savePicture struct {
userID string
username string
picture string
}
type ldapDesiredUser struct {
ldapID string
input dto.UserCreateDto
picture string
}
type ldapDesiredGroup struct {
ldapID string
input dto.UserGroupCreateDto
memberUsernames []string
}
type ldapDesiredState struct {
users []ldapDesiredUser
userIDs map[string]struct{}
groups []ldapDesiredGroup
groupIDs map[string]struct{}
}
type ldapClient interface {
Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
Bind(username, password string) error
Close() error
}
func newService(deps Dependencies) *Service {
service := &Service{
db: deps.DB,
httpClient: deps.HTTPClient,
users: deps.Users,
groups: deps.Groups,
fileStorage: deps.FileStorage,
}
service.clientFactory = service.createClient
return service
}
func (s *Service) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
if !dbConfig.LdapEnabled.IsTrue() {
return nil, apperror.LdapDisabled()
}
// Setup LDAP connection
client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
}))
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
}
// Bind as service account
err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String())
if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
}
return client, nil
}
// SyncAll synchronizes LDAP using the provided application configuration
func (s *Service) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error {
// Setup LDAP connection
client, err := s.clientFactory(dbConfig)
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
// First, we fetch all users and group from LDAP, which is our "desired state"
desiredState, err := s.fetchDesiredState(ctx, client, dbConfig)
if err != nil {
return fmt.Errorf("failed to fetch LDAP state: %w", err)
}
// Start a transaction
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return fmt.Errorf("failed to begin database transaction: %w", tx.Error)
}
defer tx.Rollback()
// Reconcile users
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig)
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
// Reconcile groups
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs, dbConfig)
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
// Commit the changes
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit changes to database: %w", err)
}
// Now that we've committed the transaction, we can perform operations on the storage layer
// First, save all new pictures
for _, sp := range savePictures {
err = s.saveProfilePicture(ctx, sp.userID, sp.picture)
if err != nil {
// This is not a fatal error
slog.Warn("Error saving profile picture for LDAP user", slog.String("username", sp.username), slog.Any("error", err))
}
}
// Delete all old files
for _, path := range deleteFiles {
err = s.fileStorage.Delete(ctx, path)
if err != nil {
// This is not a fatal error
slog.Error("Failed to delete file after LDAP sync", slog.String("path", path), slog.Any("error", err))
}
}
return nil
}
func (s *Service) fetchDesiredState(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (ldapDesiredState, error) {
// Fetch users first so we can use their DNs when resolving group members
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client, dbConfig)
if err != nil {
return ldapDesiredState{}, err
}
// Then fetch groups to complete the desired LDAP state snapshot
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig)
if err != nil {
return ldapDesiredState{}, err
}
// Apply user admin flags from the desired group membership snapshot.
// This intentionally uses the configured group member attribute rather than
// relying on a user-side reverse-membership attribute such as memberOf.
s.applyAdminGroupMembership(users, groups, dbConfig)
return ldapDesiredState{
users: users,
userIDs: userIDs,
groups: groups,
groupIDs: groupIDs,
}, nil
}
func (s *Service) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) {
if dbConfig.LdapAdminGroupName == "" {
return
}
adminUsernames := make(map[string]struct{})
for _, group := range desiredGroups {
if group.input.Name != string(dbConfig.LdapAdminGroupName) {
continue
}
for _, username := range group.memberUsernames {
adminUsernames[username] = struct{}{}
}
}
for i := range desiredUsers {
_, isAdmin := adminUsernames[desiredUsers[i].input.Username]
desiredUsers[i].input.IsAdmin = desiredUsers[i].input.IsAdmin || isAdmin
}
}
func (s *Service) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string, dbConfig *appconfig.AppConfigModel) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
// Query LDAP for all groups we want to manage
searchAttrs := []string{
dbConfig.LdapAttributeGroupName.String(),
dbConfig.LdapAttributeGroupUniqueIdentifier.String(),
dbConfig.LdapAttributeGroupMember.String(),
}
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.String(),
ldap.ScopeWholeSubtree,
0, 0, 0, false,
dbConfig.LdapUserGroupSearchFilter.String(),
searchAttrs,
[]ldap.Control{},
)
result, err := client.Search(searchReq)
if err != nil {
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
}
// Build the in-memory desired state for groups
ldapGroupIDs = make(map[string]struct{}, len(result.Entries))
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
for _, value := range result.Entries {
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.String()))
// Skip groups without a valid LDAP ID
if ldapID == "" {
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.String()))
continue
}
ldapGroupIDs[ldapID] = struct{}{}
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.String())
memberUsernames := make([]string, 0, len(groupMembers))
for _, member := range groupMembers {
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.String())
if username == "" {
continue
}
memberUsernames = append(memberUsernames, username)
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
LdapID: ldapID,
}
dto.Normalize(&syncGroup)
err = syncGroup.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user group object is not valid", slog.Any("error", err))
continue
}
desiredGroups = append(desiredGroups, ldapDesiredGroup{
ldapID: ldapID,
input: syncGroup,
memberUsernames: memberUsernames,
})
}
return desiredGroups, ldapGroupIDs, nil
}
func (s *Service) fetchUsersFromLDAP(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
// Query LDAP for all users we want to manage
searchAttrs := []string{
"sn",
"cn",
dbConfig.LdapAttributeUserUniqueIdentifier.String(),
dbConfig.LdapAttributeUserUsername.String(),
dbConfig.LdapAttributeUserEmail.String(),
dbConfig.LdapAttributeUserFirstName.String(),
dbConfig.LdapAttributeUserLastName.String(),
dbConfig.LdapAttributeUserProfilePicture.String(),
dbConfig.LdapAttributeUserDisplayName.String(),
}
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.String(),
ldap.ScopeWholeSubtree,
0, 0, 0, false,
dbConfig.LdapUserSearchFilter.String(),
searchAttrs,
[]ldap.Control{},
)
result, err := client.Search(searchReq)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
}
// Build the in-memory desired state for users and a DN lookup for group membership resolution
ldapUserIDs = make(map[string]struct{}, len(result.Entries))
usernamesByDN = make(map[string]string, len(result.Entries))
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
for _, value := range result.Entries {
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()))
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
usernamesByDN[normalizedDN] = username
}
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String()))
// Skip users without a valid LDAP ID
if ldapID == "" {
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String()))
continue
}
ldapUserIDs[ldapID] = struct{}{}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()),
// Admin status is computed after groups are loaded so it can use the
// configured group member attribute instead of a hard-coded memberOf.
IsAdmin: false,
LdapID: ldapID,
}
if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
}
dto.Normalize(&newUser)
err = newUser.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
continue
}
desiredUsers = append(desiredUsers, ldapDesiredUser{
ldapID: ldapID,
input: newUser,
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()),
})
}
return desiredUsers, ldapUserIDs, usernamesByDN, nil
}
func (s *Service) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string {
// First try the DN cache we built while loading users
username, exists := usernamesByDN[normalizeLDAPDN(member)]
if exists && username != "" {
return username
}
// Then try to extract the username directly from the DN
username = getDNProperty(usernameAttr, member)
if username != "" {
return norm.NFC.String(username)
}
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
_, err := ldap.ParseDN(member)
if err != nil {
return norm.NFC.String(member)
}
// As a fallback, query LDAP for the referenced entry
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{usernameAttr},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
return ""
}
username = userResult.Entries[0].GetAttributeValue(usernameAttr)
if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
return ""
}
return norm.NFC.String(username)
}
func (s *Service) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) error {
// Load the current LDAP-managed state from the database
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch groups from database: %w", err)
}
_, _, ldapUsersByUsername, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch users from database: %w", err)
}
// Apply creates and updates to match the desired LDAP group state
for _, desiredGroup := range desiredGroups {
memberUserIDs := make([]string, 0, len(desiredGroup.memberUsernames))
for _, username := range desiredGroup.memberUsernames {
databaseUser, exists := ldapUsersByUsername[username]
if !exists {
// The user collides with a non-LDAP user or was skipped during user sync, so we ignore it
continue
}
memberUserIDs = append(memberUserIDs, databaseUser.ID)
}
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
if databaseGroup.ID == "" {
newGroup, err := s.groups.CreateInternal(ctx, desiredGroup.input, tx)
if err != nil {
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
}
ldapGroupsByID[desiredGroup.ldapID] = newGroup
_, err = s.groups.UpdateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
continue
}
_, err = s.groups.UpdateInternal(ctx, dbConfig, databaseGroup.ID, desiredGroup.input, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groups.UpdateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
}
// Delete groups that are no longer present in LDAP
for _, group := range ldapGroupsInDB {
if group.LdapID == nil {
continue
}
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
continue
}
err = tx.
WithContext(ctx).
Delete(&model.UserGroup{}, "ldap_id = ?", *group.LdapID).
Error
if err != nil {
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
slog.Info("Deleted group", slog.String("group", group.Name))
}
return nil
}
//nolint:gocognit
func (s *Service) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) (savePictures []savePicture, deleteFiles []string, err error) {
// Load the current LDAP-managed state from the database
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
}
// Apply creates and updates to match the desired LDAP user state
savePictures = make([]savePicture, 0, len(desiredUsers))
for _, desiredUser := range desiredUsers {
databaseUser := ldapUsersByID[desiredUser.ldapID]
// If a user is found (even if disabled), enable them since they're now back in LDAP.
if databaseUser.ID != "" && databaseUser.Disabled {
err = tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", databaseUser.ID).
Update("disabled", false).
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
}
databaseUser.Disabled = false
ldapUsersByID[desiredUser.ldapID] = databaseUser
}
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.users.CreateUserInternal(ctx, dbConfig, desiredUser.input, true, tx)
if apperror.IsCode(err, apperror.CodeAlreadyInUse) {
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
}
userID = createdUser.ID
ldapUsersByID[desiredUser.ldapID] = createdUser
} else {
_, err = s.users.UpdateUserInternal(ctx, dbConfig, databaseUser.ID, desiredUser.input, false, true, tx)
if apperror.IsCode(err, apperror.CodeAlreadyInUse) {
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
}
}
if desiredUser.picture != "" {
savePictures = append(savePictures, savePicture{
userID: userID,
username: desiredUser.input.Username,
picture: desiredUser.picture,
})
}
}
// Disable or delete users that are no longer present in LDAP
deleteFiles = make([]string, 0, len(ldapUsersInDB))
for _, user := range ldapUsersInDB {
if user.LdapID == nil {
continue
}
if _, exists := ldapUserIDs[*user.LdapID]; exists {
continue
}
if dbConfig.LdapSoftDeleteUsers.IsTrue() {
err = s.users.DisableUserInternal(ctx, tx, user.ID)
if err != nil {
return nil, nil, fmt.Errorf("failed to disable user %s: %w", user.Username, err)
}
slog.Info("Disabled user", slog.String("username", user.Username))
continue
}
err = s.users.DeleteUserInternal(ctx, dbConfig, tx, user.ID, true)
if err != nil {
if apperror.IsCode(err, apperror.CodeLdapUserUpdate) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
slog.Info("Deleted user", slog.String("username", user.Username))
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
}
return savePictures, deleteFiles, nil
}
func (s *Service) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
// Load all LDAP-managed users and index them by LDAP ID and by username
err = tx.
WithContext(ctx).
Select("id, username, ldap_id, disabled").
Where("ldap_id IS NOT NULL").
Find(&users).
Error
if err != nil {
return nil, nil, nil, err
}
byLdapID = make(map[string]model.User, len(users))
byUsername = make(map[string]model.User, len(users))
for _, user := range users {
byLdapID[*user.LdapID] = user
byUsername[user.Username] = user
}
return users, byLdapID, byUsername, nil
}
func (s *Service) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
var groups []model.UserGroup
// Load all LDAP-managed groups and index them by LDAP ID
err := tx.
WithContext(ctx).
Select("id, name, ldap_id").
Where("ldap_id IS NOT NULL").
Find(&groups).
Error
if err != nil {
return nil, nil, err
}
groupsByID := make(map[string]model.UserGroup, len(groups))
for _, group := range groups {
groupsByID[*group.LdapID] = group
}
return groups, groupsByID, nil
}
func (s *Service) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
var reader io.ReadSeeker
// Accept either a URL, a base64-encoded payload, or raw binary data
_, err := url.ParseRequestURI(pictureString)
if err == nil {
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
defer cancel()
var req *http.Request
req, err = http.NewRequestWithContext(ctx, http.MethodGet, pictureString, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
var res *http.Response
res, err = s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download profile picture: %w", err)
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read profile picture: %w", err)
}
reader = bytes.NewReader(data)
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
// If the photo is a base64 encoded string, decode it
reader = bytes.NewReader(decodedPhoto)
} else {
// If the photo is a string, we assume that it's a binary string
reader = bytes.NewReader([]byte(pictureString))
}
// Update the profile picture
err = s.users.UpdateProfilePicture(parentCtx, userId, reader)
if err != nil {
return fmt.Errorf("failed to update profile picture: %w", err)
}
return nil
}
// normalizeLDAPDN returns a canonical lowercase form of a DN for use as a map key.
// Different LDAP servers may format the same DN with varying attribute type casing (e.g. "CN=" vs "cn=") or extra whitespace (e.g. "dc=example, dc=com").
// Without normalization, cache lookups in usernamesByDN would miss when a member attribute value uses a different format than the DN returned in the search entry
//
// ldap.ParseDN is used instead of simple lowercasing because it correctly handles multi-valued RDNs (joined with "+") and strips inter-component whitespace.
// If parsing fails for any reason, we fall back to a simple lowercase+trim.
func normalizeLDAPDN(dn string) string {
parsed, err := ldap.ParseDN(dn)
if err != nil {
return strings.ToLower(strings.TrimSpace(dn))
}
// Reconstruct the DN in a canonical form: lowercase type=lowercase value, with RDN components separated by "," and multi-value attributes by "+"
parts := make([]string, 0, len(parsed.RDNs))
for _, rdn := range parsed.RDNs {
attrs := make([]string, 0, len(rdn.Attributes))
for _, attr := range rdn.Attributes {
attrs = append(attrs, strings.ToLower(attr.Type)+"="+strings.ToLower(attr.Value))
}
parts = append(parts, strings.Join(attrs, "+"))
}
return strings.Join(parts, ",")
}
// getDNProperty returns the value of a property from a LDAP identifier
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
func getDNProperty(property string, str string) string {
// Example format is "CN=username,ou=people,dc=example,dc=com"
// First we split at the comma
property = strings.ToLower(property)
l := len(property) + 1
for v := range strings.SplitSeq(str, ",") {
v = strings.TrimSpace(v)
if len(v) > l && strings.ToLower(v)[0:l] == property+"=" {
return v[l:]
}
}
// CN not found, return an empty string
return ""
}
// convertLdapIdToString converts LDAP IDs to valid UTF-8 strings.
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
func convertLdapIdToString(ldapId string) string {
if utf8.ValidString(ldapId) {
return norm.NFC.String(ldapId)
}
// Try to parse as binary UUID (16 bytes)
if len(ldapId) == 16 {
if parsedUUID, err := uuid.FromBytes([]byte(ldapId)); err == nil {
return parsedUUID.String()
}
}
// As a last resort, encode as base64 to make it UTF-8 safe
return base64.StdEncoding.EncodeToString([]byte(ldapId))
}
+565
View File
@@ -0,0 +1,565 @@
package ldapsync
import (
"net/http"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeLDAPClient struct {
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
}
func TestCreateLDAPClientRejectsDisabledConfiguration(t *testing.T) {
svc := newService(Dependencies{})
_, err := svc.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"})
require.True(t, apperror.IsCode(err, apperror.CodeLdapDisabled))
}
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
if c.searchFn == nil {
return nil, nil
}
return c.searchFn(searchRequest)
}
func (c *fakeLDAPClient) Bind(_, _ string) error {
return nil
}
func (c *fakeLDAPClient) Close() error {
return nil
}
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-team"},
"cn": {"team"},
"member": {
"UID=Alice, OU=People, DC=example, DC=com",
"uid=bob, ou=people, dc=example, dc=com",
},
}),
),
))
aliceLdapID := "u-alice"
missingLdapID := "u-missing"
teamLdapID := "g-team"
oldGroupLdapID := "g-old"
require.NoError(t, db.Create(&model.User{
Username: "alice-old",
Email: new("alice-old@example.com"),
EmailVerified: true,
FirstName: "Old",
LastName: "Name",
DisplayName: "Old Name",
LdapID: &aliceLdapID,
Disabled: true,
}).Error)
require.NoError(t, db.Create(&model.User{
Username: "missing",
Email: new("missing@example.com"),
EmailVerified: true,
FirstName: "Missing",
LastName: "User",
DisplayName: "Missing User",
LdapID: &missingLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "team-old",
FriendlyName: "team-old",
LdapID: &teamLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "old-group",
FriendlyName: "old-group",
LdapID: &oldGroupLdapID,
}).Error)
err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig())
require.NoError(t, err)
var alice model.User
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
assert.Equal(t, "alice", alice.Username)
assert.Equal(t, new("alice@example.com"), alice.Email)
assert.Equal(t, "Alice", alice.FirstName)
assert.Equal(t, "Jones", alice.LastName)
assert.Equal(t, "Alice Jones", alice.DisplayName)
assert.True(t, alice.IsAdmin)
assert.False(t, alice.Disabled)
var bob model.User
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
assert.Equal(t, "bob", bob.Username)
assert.Equal(t, "Bob Brown", bob.DisplayName)
var missing model.User
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
assert.True(t, missing.Disabled)
var oldGroupCount int64
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
assert.Zero(t, oldGroupCount)
var team model.UserGroup
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
assert.Equal(t, "team", team.Name)
assert.Equal(t, "team", team.FriendlyName)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
}
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)"
appCfg.LdapAttributeGroupMember = "memberUid"
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=users,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-users"},
"cn": {"users"},
"memberUid": {"alice", "bob", "unknown"},
}),
),
))
err := service.SyncAll(t.Context(), appCfg)
require.NoError(t, err)
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error)
assert.Equal(t, "users", group.Name)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(group.Users))
}
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Doe"},
"displayName": {"Alice Doe"},
}),
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alicia"},
"sn": {"Doe"},
"displayName": {"Alicia Doe"},
}),
),
ldapSearchResult(
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team-renamed"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
),
))
err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig())
require.NoError(t, err)
var users []model.User
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
require.Len(t, users, 1)
assert.Equal(t, "alice", users[0].Username)
assert.Equal(t, "Alicia", users[0].FirstName)
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
var groups []model.UserGroup
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
require.Len(t, groups, 1)
assert.Equal(t, "team-renamed", groups[0].Name)
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
}
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
tests := []struct {
name string
appConfig *appconfig.AppConfigModel
groupEntry *ldap.Entry
groupName string
groupLookup string
}{
{
name: "memberOf missing on user",
appConfig: defaultTestLDAPAppConfig(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "admins",
groupLookup: "g-admins",
},
{
name: "configured group name attribute differs from DN RDN",
appConfig: func() *appconfig.AppConfigModel {
cfg := defaultTestLDAPAppConfig()
cfg.LdapAttributeGroupName = "displayName"
cfg.LdapAdminGroupName = "pocketid.admin"
return cfg
}(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-display-admins"},
"cn": {"admins"},
"displayName": {"pocketid.admin"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "pocketid.admin",
groupLookup: "g-display-admins",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-testadmin"},
"uid": {"testadmin"},
"mail": {"testadmin@example.com"},
"givenName": {"Test"},
"sn": {"Admin"},
"displayName": {""},
}),
),
ldapSearchResult(tt.groupEntry),
))
err := service.SyncAll(t.Context(), tt.appConfig)
require.NoError(t, err)
var user model.User
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
assert.True(t, user.IsAdmin)
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
assert.Equal(t, tt.groupName, group.Name)
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
})
}
}
func newTestLdapService(t *testing.T, client ldapClient) (*Service, *gorm.DB) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
// The sync is exercised against the real user and group services, so the assertions below can check what actually lands in the database
groupService := service.NewUserGroupService(db, nil)
userService := service.NewUserService(
db,
nil,
nil,
service.NewCustomClaimService(db),
service.NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
)
svc := newService(Dependencies{
DB: db,
HTTPClient: &http.Client{},
FileStorage: fileStorage,
Users: userService,
Groups: groupService,
})
svc.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
return client, nil
}
return svc, db
}
func defaultTestLDAPAppConfig() *appconfig.AppConfigModel {
return &appconfig.AppConfigModel{
RequireUserEmail: "false",
LdapEnabled: "true",
LdapBase: "dc=example,dc=com",
LdapUserSearchFilter: "(objectClass=person)",
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
LdapAttributeUserUniqueIdentifier: "entryUUID",
LdapAttributeUserUsername: "uid",
LdapAttributeUserEmail: "mail",
LdapAttributeUserFirstName: "givenName",
LdapAttributeUserLastName: "sn",
LdapAttributeUserDisplayName: "displayName",
LdapAttributeUserProfilePicture: "jpegPhoto",
LdapAttributeGroupMember: "member",
LdapAttributeGroupUniqueIdentifier: "entryUUID",
LdapAttributeGroupName: "cn",
LdapAdminGroupName: "admins",
LdapSoftDeleteUsers: "true",
}
}
func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
return &fakeLDAPClient{
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
switch searchRequest.Filter {
case "(objectClass=person)":
return userResult, nil
case "(objectClass=groupOfNames)", "(objectClass=posixGroup)":
return groupResult, nil
default:
return &ldap.SearchResult{}, nil
}
},
}
}
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
return &ldap.SearchResult{Entries: entries}
}
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
entry := &ldap.Entry{
DN: dn,
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
}
for name, values := range attrs {
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
Name: name,
Values: values,
})
}
return entry
}
func usernames(users []model.User) []string {
result := make([]string, 0, len(users))
for _, user := range users {
result = append(result, user.Username)
}
return result
}
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string
property string
dn string
expectedResult string
}{
{
name: "simple case",
property: "cn",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "username",
},
{
name: "property not found",
property: "uid",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "",
},
{
name: "mixed case property",
property: "CN",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "username",
},
{
name: "mixed case DN",
property: "cn",
dn: "CN=username,OU=people,DC=example,DC=com",
expectedResult: "username",
},
{
name: "spaces in DN",
property: "cn",
dn: "cn=username, ou=people, dc=example, dc=com",
expectedResult: "username",
},
{
name: "value with special characters",
property: "cn",
dn: "cn=user.name+123,ou=people,dc=example,dc=com",
expectedResult: "user.name+123",
},
{
name: "empty DN",
property: "cn",
dn: "",
expectedResult: "",
},
{
name: "empty property",
property: "",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDNProperty(tt.property, tt.dn)
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
})
}
}
func TestNormalizeLDAPDN(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "already normalized",
input: "cn=alice,dc=example,dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase attribute types",
input: "CN=Alice,DC=example,DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "spaces after commas",
input: "cn=alice, dc=example, dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase types and spaces",
input: "CN=Alice, DC=example, DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "multi-valued RDN",
input: "cn=alice+uid=a123,dc=example,dc=com",
expected: "cn=alice+uid=a123,dc=example,dc=com",
},
{
name: "invalid DN falls back to lowercase+trim",
input: " NOT A VALID DN ",
expected: "not a valid dn",
},
{
name: "empty string",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeLDAPDN(tt.input)
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
})
}
}
func TestConvertLdapIdToString(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "valid UTF-8 string",
input: "simple-utf8-id",
expected: "simple-utf8-id",
},
{
name: "binary UUID (16 bytes)",
input: string([]byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1}),
expected: "12345678-9abc-def0-1234-56789abcdef1",
},
{
name: "non-UTF8, non-UUID returns base64",
input: string([]byte{0xff, 0xfe, 0xfd, 0xfc}),
expected: "//79/A==",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input)
assert.Equal(t, tt.expected, got)
})
}
}