feat: migrate one-time and signup tokens to an actor (#1611)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Alessandro (Ale) Segala
2026-07-26 15:32:43 +02:00
committed by GitHub
co-authored by Elias Schneider
parent 531bb5f0cf
commit a1b4e1d2b2
37 changed files with 7989 additions and 905 deletions
@@ -98,6 +98,34 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) {
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
}
// NewActorStateStore creates a minimal actor host that can read and write actor state directly, without joining the cluster or binding a network port.
// It's meant for short-lived contexts such as CLI commands that need to persist actor state (for example, one-time access tokens) without running the full actor host.
// The returned host must NOT be Run(): only direct state operations (Get/Set/Delete on state) are supported, and they require the actor state tables to already exist, which is the case whenever the server has run at least once against this database.
func NewActorStateStore(db *gorm.DB, pg *pgxpool.Pool) (*local.Host, error) {
opts := &NewActorsOpts{DB: db, Postgres: pg}
if pg == nil {
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
}
opts.SQLite = sqlDB
}
providerOpt, err := opts.getProvider()
if err != nil {
return nil, err
}
return local.NewHost(
// The address is required by the host but never bound, since the host is not Run
local.WithAddress("127.0.0.1:1"),
local.WithLogger(slog.Default().With("scope", "actor-state-store")),
// The health-check deadline only needs to exceed the provider's query timeout to pass validation
local.WithHostHealthCheckDeadline(90*time.Second),
providerOpt,
)
}
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
switch {
case o.Postgres != nil && o.SQLite != nil:
+3
View File
@@ -106,6 +106,9 @@ func Bootstrap(ctx context.Context) error {
}
services = append(services, svc.appLockService.RunRenewal)
// Migrate the pre-actor signup tokens into their actors, once the actor host is ready
services = append(services, actorsReady.Await(svc.userSignUpModule.RunSignupTokenMigration))
// Acquire the lock from the app lock service
waitUntil, err := svc.appLockService.Acquire(ctx, false)
if errors.Is(err, service.ErrLockUnavailable) {
@@ -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.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
testService, err := service.NewTestService(db, svc.actors, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)
@@ -158,7 +158,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.oneTimeAccessService, svc.webauthnModule)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
@@ -171,6 +171,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
authMiddleware.Add(),
rateLimitMiddleware.Add(middleware.RateLimitSignup),
)
svc.oneTimeAccessModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
authMiddleware.WithAdminNotRequired().Add(),
rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken),
rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
@@ -14,6 +14,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"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"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
@@ -21,28 +22,29 @@ import (
)
type services struct {
appConfigService *appconfig.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
oneTimeAccessService *service.OneTimeAccessService
appConfigService *appconfig.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
oneTimeAccessModule *onetimeaccess.Module
apiModule *api.Module
actors *local.Host
}
// Initializes all services
@@ -56,7 +58,9 @@ func initServices(
fileStorage storage.FileStorage,
scheduler *job.Scheduler,
) (svc *services, err error) {
svc = &services{}
svc = &services{
actors: actors,
}
// Init the app config service
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
@@ -132,14 +136,30 @@ func initServices(
return nil, fmt.Errorf("failed to create API key module: %w", err)
}
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
svc.userSignUpModule, err = usersignup.New(usersignup.Dependencies{
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
UserCreator: svc.userService,
AppConfig: svc.appConfigService,
})
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create user signup module: %w", err)
}
svc.oneTimeAccessModule, err = onetimeaccess.New(onetimeaccess.Dependencies{
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
UserProvider: svc.userService,
EmailSender: service.NewOneTimeAccessEmailSender(svc.emailService),
AppConfig: svc.appConfigService,
})
if err != nil {
return nil, fmt.Errorf("failed to create one-time access module: %w", err)
}
svc.versionService = service.NewVersionService(httpClient)