Files
Alessandro (Ale) Segala ae7d7d03d7 feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When it is
empty or set to "embedded" (the default) nothing changes: Pocket ID starts
the runtime inside its own process, backed by its own database. Any other
value is the address, or a comma-separated list of addresses, of a
standalone Francis runtime; Pocket ID then connects to it as a remote actor
host and starts no embedded runtime.

Connecting to a standalone runtime also needs FRANCIS_HOST_PSK, the host
bootstrap pre-shared key the runtime is configured with, and optionally
FRANCIS_CA, the PEM-encoded cluster CA to pin before the first connection.
Without a pinned CA Francis trusts the certificate it is served on first
use, and warns about it.

The actor host is now held as the topology-agnostic francis host.Host
interface, since the concrete type depends on the configuration. The
commands that reach the actor data through Pocket ID's own database
(export, import, and one-time-access-token) fail with an explicit error
when a standalone runtime owns that data instead, rather than silently
operating on the wrong store.

The Francis dependency is temporarily pinned to a commit that adds the
host.Host interface.
2026-08-17 06:25:32 +00:00

85 lines
3.4 KiB
Go

package ldapsync
import (
"context"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
francishost "github.com/italypaleale/francis/host"
"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 francishost.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)
}