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
+10
View File
@@ -0,0 +1,10 @@
package appconfig
import (
"context"
)
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
type AppConfigResolver interface {
GetConfig(ctx context.Context) (*AppConfigModel, error)
}
@@ -17,7 +17,7 @@ import (
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.actors, svc.appConfigService, svc.jwtService, svc.ldapService, svc.fileStorage)
testService, err := service.NewTestService(db, svc.actors, svc.appConfigService, svc.jwtService, svc.ldapSyncModule, svc.fileStorage)
if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)
@@ -169,7 +169,8 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule, svc.ldapService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule)
svc.ldapSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.appConfigService, svc.userGroupService)
@@ -10,11 +10,7 @@ import (
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
}
err = scheduler.RegisterDbCleanupJobs(ctx, db)
err := scheduler.RegisterDbCleanupJobs(ctx, db)
if err != nil {
return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err)
}
@@ -15,6 +15,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/geolite"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/ldapsync"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -36,12 +37,12 @@ type services struct {
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
versionService *service.VersionService
fileStorage storage.FileStorage
apiKeyModule *apikey.Module
deviceLoginModule *devicelogin.Module
ldapSyncModule *ldapsync.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
@@ -152,7 +153,21 @@ func initServices(
svc.userGroupService = service.NewUserGroupService(db, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.userService, svc.userGroupService, fileStorage)
svc.ldapSyncModule, err = ldapsync.New(ldapsync.Dependencies{
DB: db,
Actors: actors,
HTTPClient: httpClient,
FileStorage: fileStorage,
Users: svc.userService,
Groups: svc.userGroupService,
AppConfig: svc.appConfigService,
// Disable in test environment
ScheduleDisabled: common.EnvConfig.AppEnv.IsTest(),
})
if err != nil {
return nil, fmt.Errorf("failed to create LDAP sync module: %w", err)
}
svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{
DB: db,
@@ -11,7 +11,6 @@ import (
"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/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
)
@@ -28,26 +27,22 @@ func NewAppConfigController(
authMiddleware *middleware.AuthMiddleware,
appConfigService *appconfig.AppConfigService,
emailSender TestEmailSender,
ldapService *service.LdapService,
) {
acc := &AppConfigController{
appConfigService: appConfigService,
emailSender: emailSender,
ldapService: ldapService,
}
group.GET("/application-configuration", httpserver.Handle(acc.listAppConfigHandler))
group.GET("/application-configuration/all", authMiddleware.Add(), httpserver.Handle(acc.listAllAppConfigHandler))
group.PUT("/application-configuration", authMiddleware.Add(), httpserver.Handle(acc.updateAppConfigHandler))
group.POST("/application-configuration/test-email", authMiddleware.Add(), httpserver.Handle(acc.testEmailHandler))
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), httpserver.Handle(acc.syncLdapHandler))
}
type AppConfigController struct {
appConfigService *appconfig.AppConfigService
emailSender TestEmailSender
ldapService *service.LdapService
}
// listAppConfigHandler godoc
@@ -140,27 +135,6 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) error {
return nil
}
// syncLdapHandler godoc
// @Summary Synchronize LDAP
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) error {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return err
}
err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig)
if err != nil {
return err
}
c.Status(http.StatusNoContent)
return nil
}
// testEmailHandler godoc
// @Summary Send test email
// @Description Send a test email to verify email configuration
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"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/utils/cookie"
@@ -15,10 +16,10 @@ import (
type handler struct {
service *Service
baseURL string
appConfig AppConfigProvider
appConfig appconfig.AppConfigResolver
}
func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *handler {
func newHandler(service *Service, baseURL string, appConfig appconfig.AppConfigResolver) *handler {
return &handler{
service: service,
baseURL: baseURL,
+1 -5
View File
@@ -31,10 +31,6 @@ type IPLocationResolver interface {
GetLocationByIP(ctx context.Context, ipAddress string) (country string, city string, err error)
}
type AppConfigProvider interface {
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
}
type Dependencies struct {
DB *gorm.DB
Actors *local.Host
@@ -44,7 +40,7 @@ type Dependencies struct {
Reauth ReauthenticationTokenConsumer
AuditLog AuditLogger
IPLocator IPLocationResolver
AppConfig AppConfigProvider
AppConfig appconfig.AppConfigResolver
}
type Module struct {
@@ -6,16 +6,17 @@ import (
"github.com/gin-gonic/gin"
"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"
)
type handler struct {
service *Service
appConfig AppConfigResolver
appConfig appconfig.AppConfigResolver
}
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
func newHandler(service *Service, appConfig appconfig.AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
+1 -6
View File
@@ -1,7 +1,6 @@
package emailverification
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
@@ -12,17 +11,13 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
)
type AppConfigResolver interface {
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
}
type Dependencies struct {
DB *gorm.DB
Actors *local.Host
Users UserProvider
EmailSender EmailSender
AppConfig AppConfigResolver
AppConfig appconfig.AppConfigResolver
AppURL string
}
-35
View File
@@ -1,35 +0,0 @@
package job
import (
"context"
"fmt"
"time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type LdapJobs struct {
ldapService *service.LdapService
appConfigService *appconfig.AppConfigService
}
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *appconfig.AppConfigService) error {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour (with some jitter)
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true})
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {
dbConfig, err := j.appConfigService.GetConfig(ctx)
if err != nil {
return fmt.Errorf("error load app config: %w", err)
}
if !dbConfig.LdapEnabled.IsTrue() {
return nil
}
return j.ldapService.SyncAll(ctx, dbConfig)
}
+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)
}
@@ -1,4 +1,4 @@
package service
package ldapsync
import (
"bytes"
@@ -17,22 +17,24 @@ import (
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"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"
)
type LdapService struct {
// 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
userService *UserService
groupService *UserGroupService
users UserSyncer
groups GroupSyncer
fileStorage storage.FileStorage
clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error)
}
@@ -68,20 +70,20 @@ type ldapClient interface {
Close() error
}
func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
service := &LdapService{
db: db,
httpClient: httpClient,
userService: userService,
groupService: groupService,
fileStorage: fileStorage,
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 *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
func (s *Service) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
if !dbConfig.LdapEnabled.IsTrue() {
return nil, apperror.LdapDisabled()
}
@@ -103,7 +105,7 @@ func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClie
}
// SyncAll synchronizes LDAP using the provided application configuration
func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error {
func (s *Service) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error {
// Setup LDAP connection
client, err := s.clientFactory(dbConfig)
if err != nil {
@@ -164,7 +166,7 @@ func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfig
return nil
}
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (ldapDesiredState, error) {
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 {
@@ -190,7 +192,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient,
}, nil
}
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) {
func (s *Service) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) {
if dbConfig.LdapAdminGroupName == "" {
return
}
@@ -212,7 +214,7 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
}
}
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string, dbConfig *appconfig.AppConfigModel) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
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(),
@@ -283,7 +285,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
return desiredGroups, ldapGroupIDs, nil
}
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
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",
@@ -368,7 +370,7 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient,
return desiredUsers, ldapUserIDs, usernamesByDN, nil
}
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string {
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 != "" {
@@ -413,7 +415,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
return norm.NFC.String(username)
}
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) error {
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 {
@@ -440,25 +442,25 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
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.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
_, 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.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx, dbConfig)
_, 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.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
_, 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)
}
@@ -489,7 +491,7 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
}
//nolint:gocognit
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) (savePictures []savePicture, deleteFiles []string, err error) {
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 {
@@ -520,7 +522,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig)
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
@@ -531,7 +533,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
userID = createdUser.ID
ldapUsersByID[desiredUser.ldapID] = createdUser
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig)
_, 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
@@ -561,7 +563,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
}
if dbConfig.LdapSoftDeleteUsers.IsTrue() {
err = s.userService.disableUserInternal(ctx, tx, user.ID)
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)
}
@@ -570,7 +572,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
continue
}
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig)
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)
@@ -585,7 +587,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
return savePictures, deleteFiles, nil
}
func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
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).
@@ -607,7 +609,7 @@ func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users
return users, byLdapID, byUsername, nil
}
func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
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
@@ -629,7 +631,7 @@ func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]mo
return groups, groupsByID, nil
}
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
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
@@ -666,7 +668,7 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
}
// Update the profile picture
err = s.userService.UpdateProfilePicture(parentCtx, userId, reader)
err = s.users.UpdateProfilePicture(parentCtx, userId, reader)
if err != nil {
return fmt.Errorf("failed to update profile picture: %w", err)
}
@@ -1,4 +1,4 @@
package service
package ldapsync
import (
"net/http"
@@ -12,6 +12,7 @@ import (
"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"
)
@@ -21,9 +22,9 @@ type fakeLDAPClient struct {
}
func TestCreateLDAPClientRejectsDisabledConfiguration(t *testing.T) {
service := NewLdapService(nil, nil, nil, nil, nil)
svc := newService(Dependencies{})
_, err := service.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"})
_, err := svc.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"})
require.True(t, apperror.IsCode(err, apperror.CodeLdapDisabled))
}
@@ -158,7 +159,7 @@ func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)"
appCfg.LdapAttributeGroupMember = "memberUid"
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
@@ -287,7 +288,7 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, db := newTestLdapServiceWithAppConfig(t, tt.appConfig, newFakeLDAPClient(
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-testadmin"},
@@ -316,13 +317,7 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
}
}
func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB) {
t.Helper()
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
}
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.AppConfigModel, client ldapClient) (*LdapService, *gorm.DB) {
func newTestLdapService(t *testing.T, client ldapClient) (*Service, *gorm.DB) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
@@ -330,23 +325,30 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.App
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
groupService := NewUserGroupService(db, nil)
userService := NewUserService(
// 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,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
service.NewCustomClaimService(db),
service.NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
)
service := NewLdapService(db, &http.Client{}, userService, groupService, fileStorage)
service.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
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 service, db
return svc, db
}
func defaultTestLDAPAppConfig() *appconfig.AppConfigModel {
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"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/httpserver"
@@ -17,10 +18,10 @@ const defaultTokenDuration = 15 * time.Minute
type handler struct {
service *Service
appConfig AppConfigResolver
appConfig appconfig.AppConfigResolver
}
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
func newHandler(service *Service, appConfig appconfig.AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
+1 -6
View File
@@ -31,11 +31,6 @@ type UserProvider interface {
GetUser(ctx context.Context, userID string) (model.User, error)
}
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
type AppConfigResolver interface {
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
}
type Dependencies struct {
DB *gorm.DB
Actors *local.Host
@@ -44,7 +39,7 @@ type Dependencies struct {
AuditLog AuditLogger
UserProvider UserProvider
EmailSender EmailSender
AppConfig AppConfigResolver
AppConfig appconfig.AppConfigResolver
}
type Module struct {
+10 -4
View File
@@ -43,12 +43,18 @@ import (
"github.com/pocket-id/pocket-id/backend/resources"
)
// LdapSyncer runs a full LDAP synchronization
// It's an interface so this package doesn't import the ldapsync package, which imports this one in its tests
type LdapSyncer interface {
SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error
}
type TestService struct {
db *gorm.DB
actors *local.Host
jwtService *JwtService
appConfigService *appconfig.AppConfigService
ldapService *LdapService
ldapSyncer LdapSyncer
fileStorage storage.FileStorage
externalIdPKey jwk.Key
}
@@ -63,13 +69,13 @@ const (
e2eEmailVerificationToken = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup"
)
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, fileStorage storage.FileStorage) (*TestService, error) {
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
actors: actors,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
ldapSyncer: ldapSyncer,
fileStorage: fileStorage,
}
err := s.initExternalIdP()
@@ -751,7 +757,7 @@ func (s *TestService) SyncLdap(ctx context.Context) error {
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
return s.ldapService.SyncAll(ctx, dbConfig)
return s.ldapSyncer.SyncAll(ctx, dbConfig)
}
// SetLdapTestConfig updates the LDAP configuration used by the end-to-end test server
+17 -7
View File
@@ -110,11 +110,13 @@ func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigM
}
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
return s.createInternal(ctx, input, s.db)
return s.CreateInternal(ctx, input, s.db)
}
func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) {
group = model.UserGroup{
// CreateInternal creates a user group within an existing transaction
// It's exported for the LDAP sync, which reconciles users and groups in a single transaction of its own
func (s *UserGroupService) CreateInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (model.UserGroup, error) {
group := model.UserGroup{
FriendlyName: input.FriendlyName,
Name: input.Name,
}
@@ -123,7 +125,7 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
group.LdapID = &input.LdapID
}
err = tx.
err := tx.
WithContext(ctx).
Preload("Users").
Create(&group).
@@ -160,6 +162,12 @@ func (s *UserGroupService) Update(ctx context.Context, cfg *appconfig.AppConfigM
return group, nil
}
// UpdateInternal updates a user group within an existing transaction
// It's exported for the LDAP sync, which reconciles users and groups in a single transaction of its own
func (s *UserGroupService) UpdateInternal(ctx context.Context, cfg *appconfig.AppConfigModel, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB) (model.UserGroup, error) {
return s.updateInternal(ctx, id, input, isLdapSync, tx, cfg)
}
func (s *UserGroupService) updateInternal(ctx context.Context, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (group model.UserGroup, err error) {
group, err = s.getInternal(ctx, id, tx)
if err != nil {
@@ -201,7 +209,7 @@ func (s *UserGroupService) UpdateUsers(ctx context.Context, id string, userIds [
tx.Rollback()
}()
group, err = s.updateUsersInternal(ctx, id, userIds, tx)
group, err = s.UpdateUsersInternal(ctx, id, userIds, tx)
if err != nil {
return model.UserGroup{}, err
}
@@ -214,8 +222,10 @@ func (s *UserGroupService) UpdateUsers(ctx context.Context, id string, userIds [
return group, nil
}
func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, userIds []string, tx *gorm.DB) (group model.UserGroup, err error) {
group, err = s.getInternal(ctx, id, tx)
// UpdateUsersInternal replaces the members of a user group within an existing transaction
// It's exported for the LDAP sync, which reconciles users and groups in a single transaction of its own
func (s *UserGroupService) UpdateUsersInternal(ctx context.Context, id string, userIds []string, tx *gorm.DB) (model.UserGroup, error) {
group, err := s.getInternal(ctx, id, tx)
if err != nil {
return model.UserGroup{}, err
}
+12 -5
View File
@@ -196,7 +196,7 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f
func (s *UserService) DeleteUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, allowLdapDelete bool) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete, dbConfig)
return s.DeleteUserInternal(ctx, dbConfig, tx, userID, allowLdapDelete)
})
if err != nil {
return fmt.Errorf("failed to delete user '%s': %w", userID, err)
@@ -212,7 +212,10 @@ func (s *UserService) DeleteUser(ctx context.Context, dbConfig *appconfig.AppCon
return nil
}
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool, cfg *appconfig.AppConfigModel) error {
// DeleteUserInternal deletes a user within an existing transaction
// It's exported for the LDAP sync, which deletes users that are no longer in the directory
// Note that the caller is responsible for removing the user's profile picture from the storage layer, which must happen outside of the transaction
func (s *UserService) DeleteUserInternal(ctx context.Context, cfg *appconfig.AppConfigModel, tx *gorm.DB, userID string, allowLdapDelete bool) error {
var user model.User
err := tx.
WithContext(ctx).
@@ -439,7 +442,7 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
tx.Rollback()
}()
user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx, cfg)
user, err := s.UpdateUserInternal(ctx, cfg, userID, updatedUser, updateOwnUser, isLdapSync, tx)
if err != nil {
return model.User{}, err
}
@@ -452,7 +455,9 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
return user, nil
}
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
// UpdateUserInternal updates a user within an existing transaction
// It's exported for the LDAP sync, which reconciles users and groups in a single transaction of its own
func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
return model.User{}, apperror.MissingField("email")
}
@@ -643,7 +648,9 @@ func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) er
return nil
}
func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
// DisableUserInternal disables a user within an existing transaction
// It's exported for the LDAP sync, which soft-deletes users that are no longer in the directory
func (s *UserService) DisableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
err := tx.
WithContext(ctx).
Model(&model.User{}).
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"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/httpserver"
@@ -18,10 +19,10 @@ const defaultSignupTokenDuration = time.Hour
type handler struct {
service *Service
appConfig AppConfigResolver
appConfig appconfig.AppConfigResolver
}
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
func newHandler(service *Service, appConfig appconfig.AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
+1 -6
View File
@@ -27,11 +27,6 @@ type UserCreator interface {
CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error)
}
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
type AppConfigResolver interface {
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
}
type Dependencies struct {
DB *gorm.DB
Actors *local.Host
@@ -39,7 +34,7 @@ type Dependencies struct {
Signer TokenService
AuditLog AuditLogger
UserCreator UserCreator
AppConfig AppConfigResolver
AppConfig appconfig.AppConfigResolver
}
type Module struct {
+3 -1
View File
@@ -25,9 +25,10 @@ const testActorHostPSK = "pocket-id-test-actor-host-psk-32bytes"
// NewActorHostForTest starts a single-host Francis cluster backed by the in-memory provider, runs it, and waits until it is ready to serve invocations
// The register callback, if not nil, runs after the host is created but before it starts, so callers can register actors with host.RegisterActor/host.RegisterBuiltInActor (must be called before the host is running)
// Any extra options are appended last, so they override the defaults set here, which lets a test reproduce a production host setting such as the alarm poll interval
// The host is stopped when the test ends
// The in-memory provider keeps no state on disk, so the test never touches a real database
func NewActorHostForTest(t *testing.T, register func(t *testing.T, h *local.Host)) *local.Host {
func NewActorHostForTest(t *testing.T, register func(t *testing.T, h *local.Host), extraOpts ...local.HostOption) *local.Host {
t.Helper()
address := freeLoopbackUDPAddr(t)
@@ -37,6 +38,7 @@ func NewActorHostForTest(t *testing.T, register func(t *testing.T, h *local.Host
local.WithStandaloneMemoryProvider(standalone.StandaloneMemoryOptions{}),
local.WithShutdownGracePeriod(time.Second),
}
hostOpts = append(hostOpts, extraOpts...)
h, err := local.NewHost(hostOpts...)
require.NoError(t, err)
+7 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"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/httpserver"
@@ -15,11 +16,14 @@ import (
type handler struct {
service *Service
appConfig AppConfigResolver
appConfig appconfig.AppConfigResolver
}
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
func newHandler(service *Service, appConfig appconfig.AppConfigResolver) *handler {
return &handler{
service: service,
appConfig: appConfig,
}
}
func (h *handler) beginRegistration(c *gin.Context) error {
+1 -6
View File
@@ -24,18 +24,13 @@ type AuditLogger interface {
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog
}
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
type AppConfigResolver interface {
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
}
type Dependencies struct {
DB *gorm.DB
AppURL string
Signer TokenService
AuditLog AuditLogger
AppConfig AppConfigResolver
AppConfig appconfig.AppConfigResolver
}
type Module struct {