mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 05:54:01 +03:00
Compare commits
6 Commits
v2.11.0
...
actors/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
669834d2d3 | ||
|
|
2b11164532 | ||
|
|
1d2f991be5 | ||
|
|
eac837fc22 | ||
|
|
0f6ff76165 | ||
|
|
e4d0cbefbe |
64
backend/internal/appconfig/app_config_middleware.go
Normal file
64
backend/internal/appconfig/app_config_middleware.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// appConfigCtxKey is the context key used to store the AppConfigResolver in the http.Request's context
|
||||
type appConfigCtxKey struct{}
|
||||
|
||||
type appConfigResolver func(ctx context.Context) (*AppConfigModel, error)
|
||||
|
||||
// AppConfigMiddleware is a Gin middleware that makes the application configuration available to all downstream handlers through the request's context
|
||||
type AppConfigMiddleware struct {
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewAppConfigMiddleware(appConfigService *AppConfigService) *AppConfigMiddleware {
|
||||
return &AppConfigMiddleware{
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
}
|
||||
|
||||
// Add returns a Gin middleware that stores an AppConfigResolver in the http.Request's context
|
||||
// The resolver loads the application configuration lazily on the first call and caches it for the duration of the request
|
||||
func (m *AppConfigMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqCtx := c.Request.Context()
|
||||
|
||||
// Create a cache for each request in the middleware's scope, so it's unique per each request
|
||||
var (
|
||||
once sync.Once
|
||||
cfg *AppConfigModel
|
||||
err error
|
||||
)
|
||||
// Note: the resolver accepts a context argument, it doesn't use the request's own
|
||||
// This can be used for example for tracing
|
||||
resolver := appConfigResolver(func(ctx context.Context) (*AppConfigModel, error) {
|
||||
once.Do(func() {
|
||||
cfg, err = m.appConfigService.GetConfig(ctx)
|
||||
})
|
||||
return cfg, err
|
||||
})
|
||||
|
||||
// Store the resolver in the request's context
|
||||
c.Request = c.Request.WithContext(context.WithValue(reqCtx, appConfigCtxKey{}, resolver))
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FromCtx retrieves the app config from the context
|
||||
func FromCtx(ctx context.Context) (*AppConfigModel, error) {
|
||||
resolver, ok := ctx.Value(appConfigCtxKey{}).(appConfigResolver)
|
||||
if !ok || resolver == nil {
|
||||
// Indicates a development-time error
|
||||
return nil, errors.New("middleware AppConfigMiddleware was not registered for the handler")
|
||||
}
|
||||
|
||||
return resolver(ctx)
|
||||
}
|
||||
112
backend/internal/appconfig/appconfig_actor.go
Normal file
112
backend/internal/appconfig/appconfig_actor.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
|
||||
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
|
||||
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it
|
||||
|
||||
// AppConfigActorType is the actor type for the AppConfig actor
|
||||
const AppConfigActorType = "AppConfig"
|
||||
|
||||
// appConfigActor is a singleton actor that manages the dynamic app configuration
|
||||
type appConfigActor struct {
|
||||
log *slog.Logger
|
||||
client actor.Client[*AppConfigModel]
|
||||
}
|
||||
|
||||
// appConfigActorBootstrap is the type for the payload of the init method
|
||||
type appConfigActorBootstrap struct {
|
||||
LegacyConfig map[string]string
|
||||
}
|
||||
|
||||
// NewAppConfigActor allocates a new AppConfig actor
|
||||
// It satisfies actor.Factory
|
||||
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
|
||||
log := slog.
|
||||
With(
|
||||
slog.String("scope", "actor"),
|
||||
slog.String("actorType", AppConfigActorType),
|
||||
slog.String("actorID", actorID),
|
||||
)
|
||||
|
||||
log.Info("AppConfig actor created")
|
||||
|
||||
return &appConfigActor{
|
||||
log: log,
|
||||
client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap implements actor.ActorBootstrapper for the singleton actor
|
||||
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// If we already have a state, nothing else to do
|
||||
if state != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the request data contains legacy config to init from
|
||||
if data != nil {
|
||||
payload := appConfigActorBootstrap{}
|
||||
err = data.Decode(&payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method 'init': %w", err)
|
||||
}
|
||||
|
||||
if len(payload.LegacyConfig) > 0 {
|
||||
state, err = fromLegacyConfig(payload.LegacyConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have no state, generate a new default config
|
||||
if state == nil {
|
||||
state = getDefaultConfig()
|
||||
}
|
||||
|
||||
// Save the updated state
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, state, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
// Only supported method is "get"
|
||||
if method != "get" {
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// Return the state
|
||||
return state, nil
|
||||
}
|
||||
47
backend/internal/appconfig/legacy_config.go
Normal file
47
backend/internal/appconfig/legacy_config.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
// loadLegacyConfig loads the legacy config from the database
|
||||
// This was migrated to the "config_migrated" key in the kv table
|
||||
func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) {
|
||||
// Retrieve the migrated config from the kv table
|
||||
row := model.KV{
|
||||
Key: "config_migrated",
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
err := db.WithContext(ctx).First(&row).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// There's no migrated config in the database, nothing to do
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("failed to load migrated config from the database: %w", err)
|
||||
case row.Value == nil || len(*row.Value) == 0:
|
||||
// Also no migrated config, nothing to do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The value is a JSON-encoded dictionary
|
||||
res := map[string]string{}
|
||||
err = json.Unmarshal([]byte(*row.Value), &res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing migrated config: %w", err)
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
271
backend/internal/appconfig/model.go
Normal file
271
backend/internal/appconfig/model.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/go-kit/utils"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
type AppConfigModel struct {
|
||||
// General
|
||||
AppName AppConfigValue `json:"appName" public:"true"`
|
||||
SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
|
||||
HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
|
||||
EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
|
||||
AccentColor AppConfigValue `json:"accentColor" public:"true"`
|
||||
DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
|
||||
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
|
||||
AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
|
||||
|
||||
SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
|
||||
SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
|
||||
// Email
|
||||
RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
|
||||
SmtpHost AppConfigValue `json:"smtpHost"`
|
||||
SmtpPort AppConfigValue `json:"smtpPort"`
|
||||
SmtpFrom AppConfigValue `json:"smtpFrom"`
|
||||
SmtpUser AppConfigValue `json:"smtpUser"`
|
||||
SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
|
||||
SmtpTls AppConfigValue `json:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
|
||||
EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
|
||||
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
|
||||
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
|
||||
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
|
||||
// LDAP
|
||||
LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
|
||||
LdapUrl AppConfigValue `json:"ldapUrl"`
|
||||
LdapBindDn AppConfigValue `json:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
|
||||
LdapBase AppConfigValue `json:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
|
||||
LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
|
||||
}
|
||||
|
||||
// AppConfigValue holds a value
|
||||
type AppConfigValue string
|
||||
|
||||
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
|
||||
func (a AppConfigValue) IsTrue() bool {
|
||||
return utils.IsTruthy(string(a))
|
||||
}
|
||||
|
||||
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
|
||||
func (a AppConfigValue) AsDurationMinutes() time.Duration {
|
||||
val, err := strconv.Atoi(string(a))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(val) * time.Minute
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer
|
||||
func (a AppConfigValue) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
func getDefaultConfig() *AppConfigModel {
|
||||
// Values are the default ones
|
||||
return &AppConfigModel{
|
||||
// General
|
||||
AppName: "Pocket ID",
|
||||
SessionDuration: "60",
|
||||
HomePageURL: "/settings/account",
|
||||
EmailsVerified: "false",
|
||||
DisableAnimations: "false",
|
||||
AllowOwnAccountEdit: "true",
|
||||
AllowUserSignups: "disabled",
|
||||
SignupDefaultUserGroupIDs: "[]",
|
||||
SignupDefaultCustomClaims: "[]",
|
||||
AccentColor: "default",
|
||||
// Email
|
||||
RequireUserEmail: "true",
|
||||
SmtpHost: "",
|
||||
SmtpPort: "",
|
||||
SmtpFrom: "",
|
||||
SmtpUser: "",
|
||||
SmtpPassword: "",
|
||||
SmtpTls: "none",
|
||||
SmtpSkipCertVerify: "false",
|
||||
EmailLoginNotificationEnabled: "false",
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
||||
EmailOneTimeAccessAsAdminEnabled: "false",
|
||||
EmailApiKeyExpirationEnabled: "false",
|
||||
EmailVerificationEnabled: "false",
|
||||
// LDAP
|
||||
LdapEnabled: "false",
|
||||
LdapUrl: "",
|
||||
LdapBindDn: "",
|
||||
LdapBindPassword: "",
|
||||
LdapBase: "",
|
||||
LdapUserSearchFilter: "(objectClass=person)",
|
||||
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
|
||||
LdapSkipCertVerify: "false",
|
||||
LdapAttributeUserUniqueIdentifier: "",
|
||||
LdapAttributeUserUsername: "",
|
||||
LdapAttributeUserEmail: "",
|
||||
LdapAttributeUserFirstName: "",
|
||||
LdapAttributeUserLastName: "",
|
||||
LdapAttributeUserDisplayName: "cn",
|
||||
LdapAttributeUserProfilePicture: "",
|
||||
LdapAttributeGroupMember: "member",
|
||||
LdapAttributeGroupUniqueIdentifier: "",
|
||||
LdapAttributeGroupName: "",
|
||||
LdapAdminGroupName: "",
|
||||
LdapSoftDeleteUsers: "true",
|
||||
}
|
||||
}
|
||||
|
||||
// fromLegacyConfig builds an appConfigModel from a legacy config map
|
||||
// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type
|
||||
// Keys that are missing (or have an empty value) retain the default value
|
||||
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
|
||||
// Start from the default configuration, then override with the values from the legacy config
|
||||
dest := getDefaultConfig()
|
||||
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Look up the value in the legacy config
|
||||
// If the key is missing or the value is empty, we keep the default value
|
||||
value, ok := legacyCfg[key]
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Cast the string value to the field's type
|
||||
fv := rv.Field(i)
|
||||
switch fv.Kind() { //nolint:exhaustive
|
||||
case reflect.String:
|
||||
fv.SetString(value)
|
||||
case reflect.Bool:
|
||||
fv.SetBool(utils.IsTruthy(value))
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err)
|
||||
}
|
||||
fv.SetInt(n)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key)
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// Replace updates every configuration property with the values from the input DTO
|
||||
// An empty string value resets the corresponding property to its default value
|
||||
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) error {
|
||||
// Collect the values from the input DTO into a map, keyed by the "json" tag
|
||||
inRv := reflect.ValueOf(input)
|
||||
inRt := inRv.Type()
|
||||
values := make(map[string]string, inRt.NumField())
|
||||
for i := range inRt.NumField() {
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",")
|
||||
values[key] = inRv.Field(i).String()
|
||||
}
|
||||
|
||||
// Iterate through all the properties, setting each one from the input
|
||||
// Properties that are missing from the input or have an empty value are reset to their default
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
rv := reflect.ValueOf(m).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
|
||||
value, ok := values[key]
|
||||
if !ok || value == "" {
|
||||
value = defaults.Field(i).String()
|
||||
}
|
||||
|
||||
rv.Field(i).SetString(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update sets configuration properties from the provided key-value pairs
|
||||
// Keys correspond to the "json" tags on the model
|
||||
// An empty string value resets the property to its default value
|
||||
func (m *AppConfigModel) Update(keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(m).Elem()
|
||||
rt := rv.Type()
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
|
||||
// Iterate through the key-value pairs
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
key := keysAndValues[i-1]
|
||||
value := keysAndValues[i]
|
||||
|
||||
// Find the field in the struct whose "json" tag matches
|
||||
fieldIdx := -1
|
||||
for j := range rt.NumField() {
|
||||
// Separate the key (before the comma) from any optional attributes after
|
||||
tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",")
|
||||
if tagValue == key {
|
||||
fieldIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if fieldIdx < 0 {
|
||||
return AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
// An empty string means we use the default value for the property
|
||||
if value == "" {
|
||||
value = defaults.Field(fieldIdx).String()
|
||||
}
|
||||
|
||||
rv.Field(fieldIdx).SetString(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppConfigKeyNotFoundError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
|
||||
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
|
||||
return ok
|
||||
}
|
||||
201
backend/internal/appconfig/model_test.go
Normal file
201
backend/internal/appconfig/model_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place.
|
||||
func dtoWithMarkerValues() dto.AppConfigUpdateDto {
|
||||
var input dto.AppConfigUpdateDto
|
||||
rv := reflect.ValueOf(&input).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
rv.Field(i).SetString("marker-" + key)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Replace(t *testing.T) {
|
||||
t.Run("populates every property from the DTO", func(t *testing.T) {
|
||||
input := dtoWithMarkerValues()
|
||||
|
||||
var m AppConfigModel
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Each model property must hold the marker built from its own "json" key.
|
||||
// This also asserts that the model and the DTO share the same set of keys.
|
||||
rv := reflect.ValueOf(&m).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty values fall back to their default", func(t *testing.T) {
|
||||
defaults := getDefaultConfig()
|
||||
|
||||
// Start from all-markers, then blank out a few properties whose default is non-empty
|
||||
input := dtoWithMarkerValues()
|
||||
input.AppName = ""
|
||||
input.SessionDuration = ""
|
||||
input.SmtpTls = ""
|
||||
input.LdapUserSearchFilter = ""
|
||||
|
||||
var m AppConfigModel
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Blanked properties are reset to their default
|
||||
assert.Equal(t, defaults.AppName, m.AppName)
|
||||
assert.Equal(t, defaults.SessionDuration, m.SessionDuration)
|
||||
assert.Equal(t, defaults.SmtpTls, m.SmtpTls)
|
||||
assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter)
|
||||
|
||||
// A property that was provided keeps the provided value
|
||||
assert.Equal(t, "marker-homePageUrl", m.HomePageURL)
|
||||
})
|
||||
|
||||
t.Run("an empty DTO resets every property to its default", func(t *testing.T) {
|
||||
// Pre-populate with junk to prove Replace overwrites existing state
|
||||
m := AppConfigModel{
|
||||
AppName: "Custom Name",
|
||||
LdapEnabled: "true",
|
||||
SmtpHost: "smtp.example.com",
|
||||
}
|
||||
|
||||
err := m.Replace(dto.AppConfigUpdateDto{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, *getDefaultConfig(), m)
|
||||
})
|
||||
|
||||
t.Run("provided values overwrite existing non-default values", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
m.AppName = "Old Name"
|
||||
m.LdapEnabled = "true"
|
||||
|
||||
input := dto.AppConfigUpdateDto{}
|
||||
input.AppName = "New Name"
|
||||
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Explicitly provided value wins
|
||||
assert.Equal(t, "New Name", m.AppName)
|
||||
// Everything else in the DTO was empty, so it is reset to the default
|
||||
assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled)
|
||||
})
|
||||
|
||||
t.Run("stores raw string values without type coercion", func(t *testing.T) {
|
||||
input := dto.AppConfigUpdateDto{}
|
||||
input.SessionDuration = "120" // int-tagged property
|
||||
input.LdapEnabled = "true" // bool-tagged property
|
||||
|
||||
var m AppConfigModel
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "120", m.SessionDuration)
|
||||
assert.Equal(t, "true", m.LdapEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Update(t *testing.T) {
|
||||
t.Run("updates a single property", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("appName", "My App")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "My App", m.AppName)
|
||||
})
|
||||
|
||||
t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("appName", "My App", "homePageUrl", "/home", "ldapEnabled", "true")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "My App", m.AppName)
|
||||
assert.Equal(t, "/home", m.HomePageURL)
|
||||
assert.Equal(t, "true", m.LdapEnabled)
|
||||
// A property that was not part of the update keeps its previous value
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("an empty value resets the property to its default", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
m.SmtpTls = "tls" // default is "none"
|
||||
m.SessionDuration = "120" // default is "60"
|
||||
|
||||
err := m.Update("smtpTls", "", "sessionDuration", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls)
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("stores raw string values without type coercion", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("sessionDuration", "120", "disableAnimations", "true")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "120", m.SessionDuration)
|
||||
assert.Equal(t, "true", m.DisableAnimations)
|
||||
})
|
||||
|
||||
t.Run("later value wins for a repeated key", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("appName", "First", "appName", "Second")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Second", m.AppName)
|
||||
})
|
||||
|
||||
t.Run("no arguments is a no-op", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
before := *m
|
||||
|
||||
err := m.Update()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, before, *m)
|
||||
})
|
||||
|
||||
t.Run("an odd number of arguments returns an error", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
before := *m
|
||||
|
||||
err := m.Update("appName")
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, "invalid number of arguments received")
|
||||
|
||||
// The config must not have been modified
|
||||
assert.Equal(t, before, *m)
|
||||
})
|
||||
|
||||
t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("thisKeyDoesNotExist", "value")
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'")
|
||||
|
||||
notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
|
||||
})
|
||||
}
|
||||
301
backend/internal/appconfig/service.go
Normal file
301
backend/internal/appconfig/service.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"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/tracing"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type AppConfigService struct {
|
||||
actSvc *actor.Service
|
||||
envConfig *AppConfigModel
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{}
|
||||
|
||||
// If the UI config is disabled, we do not need to init the config actor
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
service.envConfig, err = service.loadDbConfigFromEnv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app config from the env: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if"
|
||||
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
|
||||
defer tracing.End(span, err)
|
||||
|
||||
// Load the legacy config if any, which we need to send to the actor as bootstrap data
|
||||
legacyCfg, err := LoadLegacyConfig(ctx, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading legacy config: %w", err)
|
||||
}
|
||||
|
||||
// Register the AppConfig actor
|
||||
// This is a singleton actor and it's bootstrapped with the legacy config if present
|
||||
bootstrapData := &appConfigActorBootstrap{
|
||||
LegacyConfig: legacyCfg,
|
||||
}
|
||||
err = actors.RegisterSingletonActor(
|
||||
AppConfigActorType, NewAppConfigActor,
|
||||
local.WithBootstrapData(bootstrapData),
|
||||
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
|
||||
}
|
||||
|
||||
service.actSvc = actors.Service()
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetConfig returns the application configuration
|
||||
// Important: Treat the object as read-only: do not modify its properties directly!
|
||||
func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) {
|
||||
// If the UI config is disabled, only load from the env
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return s.envConfig, nil
|
||||
}
|
||||
|
||||
// Retrieve the config from the actor
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving config from actor: %w", err)
|
||||
}
|
||||
if res == nil {
|
||||
return nil, errors.New("config actor response was empty")
|
||||
}
|
||||
|
||||
var cfg AppConfigModel
|
||||
err = res.Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding config actor response: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
// Perform an "upsert" if the key already exists, replacing the value
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).
|
||||
Create(&dbUpdate).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update config in database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := getDefaultConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
|
||||
for field := range rt.Fields() {
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
if value == "" {
|
||||
// Ignore errors here as we know the key exists
|
||||
defaultValue, _ := defaultCfg.FieldByKey(key)
|
||||
err = cfg.UpdateField(key, defaultValue)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
// Return the updated config
|
||||
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateAppConfigValues updates the application configuration values in the database.
|
||||
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Start the transaction
|
||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
key := keysAndValues[i-1]
|
||||
value := keysAndValues[i]
|
||||
|
||||
// Ensure that the field is valid
|
||||
// We do this by grabbing the default value
|
||||
var defaultValue string
|
||||
defaultValue, err := defaultCfg.FieldByKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
if value == "" {
|
||||
err = cfg.UpdateField(key, defaultValue)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
|
||||
// First, start from the default configuration
|
||||
dest := getDefaultConfig()
|
||||
|
||||
// Iterate through each field
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the key and internal tag values
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||
|
||||
// Set the value if it's set
|
||||
value, ok := os.LookupEnv(envVarName)
|
||||
if ok {
|
||||
rv.Field(i).Set(reflect.ValueOf(value))
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's sensitive, we also allow reading from file
|
||||
if attrs == "sensitive" {
|
||||
fileName := os.Getenv(envVarName + "_FILE")
|
||||
if fileName != "" {
|
||||
// #nosec G703 - Value is provided by admin
|
||||
b, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
|
||||
}
|
||||
|
||||
rv.Field(i).Set(reflect.ValueOf(string(b)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
18
backend/internal/appconfig/testing_unit.go
Normal file
18
backend/internal/appconfig/testing_unit.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build unit
|
||||
|
||||
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
||||
package appconfig
|
||||
|
||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
|
||||
if config == nil {
|
||||
// If there's no config, set the default one
|
||||
config = getDefaultConfig()
|
||||
}
|
||||
|
||||
service := &AppConfigService{
|
||||
envConfig: config,
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
services = append(services, actorsRun)
|
||||
|
||||
// Create all services
|
||||
svc, err := initServices(ctx, db, instanceID, httpClient, imageExtensions, fileStorage, scheduler)
|
||||
svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"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/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
@@ -19,7 +21,7 @@ import (
|
||||
)
|
||||
|
||||
type services struct {
|
||||
appConfigService *service.AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
@@ -44,10 +46,20 @@ type services struct {
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
|
||||
func initServices(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
instanceID string,
|
||||
actors *local.Host,
|
||||
httpClient *http.Client,
|
||||
imageExtensions map[string]string,
|
||||
fileStorage storage.FileStorage,
|
||||
scheduler *job.Scheduler,
|
||||
) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
|
||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||
// Init the app config service
|
||||
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
|
||||
20
backend/internal/common/internal_errors.go
Normal file
20
backend/internal/common/internal_errors.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported
|
||||
type ErrUnsupportedActorMethod struct {
|
||||
Method string
|
||||
}
|
||||
|
||||
func (e ErrUnsupportedActorMethod) Error() string {
|
||||
return "method '" + e.Method + "' unsupported for actor invocation"
|
||||
}
|
||||
|
||||
func (e ErrUnsupportedActorMethod) Is(target error) bool {
|
||||
// Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod
|
||||
_, ok := errors.AsType[ErrUnsupportedActorMethod](target)
|
||||
return ok
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
appConfigService *service.AppConfigService,
|
||||
appConfigService *appconfig.AppConfigService,
|
||||
emailService *service.EmailService,
|
||||
ldapService *service.LdapService,
|
||||
) {
|
||||
@@ -38,7 +39,7 @@ func NewAppConfigController(
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
emailService *service.EmailService
|
||||
ldapService *service.LdapService
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
@@ -21,7 +23,7 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
// @Tags Users
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) {
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *appconfig.AppConfigService) {
|
||||
uc := UserController{
|
||||
userService: userService,
|
||||
oneTimeAccessService: oneTimeAccessService,
|
||||
@@ -64,7 +66,7 @@ type UserController struct {
|
||||
userService *service.UserService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
webAuthnService *webauthn.Module
|
||||
appConfigService *service.AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
}
|
||||
|
||||
// getUserGroupsHandler godoc
|
||||
@@ -508,6 +510,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/one-time-access-token/{token} [post]
|
||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
cfg, err := appconfig.FromCtx(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
loginCode := c.Param("token")
|
||||
// reject invalid length login codes
|
||||
if len(loginCode) != 6 && len(loginCode) != 16 {
|
||||
@@ -523,12 +531,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
err = dto.MapStruct(user, &userDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
|
||||
@@ -8,17 +8,18 @@ import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
type ApiKeyEmailJobs struct {
|
||||
apiKeyModule *apikey.Module
|
||||
appConfigService *service.AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
emailService *service.EmailService
|
||||
}
|
||||
|
||||
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error {
|
||||
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailService *service.EmailService) error {
|
||||
jobs := &ApiKeyEmailJobs{
|
||||
apiKeyModule: apiKeyModule,
|
||||
appConfigService: appConfigService,
|
||||
@@ -30,8 +31,13 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *a
|
||||
}
|
||||
|
||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||
dbConfig, err := j.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error load app config: %w", err)
|
||||
}
|
||||
|
||||
// Skip if the feature is disabled
|
||||
if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() {
|
||||
if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,19 @@ 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 *service.AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
}
|
||||
|
||||
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
||||
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)
|
||||
@@ -20,7 +22,12 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
|
||||
}
|
||||
|
||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||
if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
dbConfig, err := j.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error load app config: %w", err)
|
||||
}
|
||||
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -33,8 +34,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
appConfigService := appconfig.NewTestAppConfigService(nil)
|
||||
|
||||
instanceID, err := instanceid.Load(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
@@ -49,7 +49,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService)
|
||||
|
||||
user := createUserForAuthMiddlewareTest(t, db)
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user, "")
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKeyToken := "middleware-test-api-key-raw-token"
|
||||
|
||||
@@ -39,6 +39,7 @@ func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[strin
|
||||
for name, rl := range limiters {
|
||||
services[name] = rl.Service(svc)
|
||||
}
|
||||
|
||||
return services
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -86,7 +85,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues
|
||||
cfgValue := reflect.ValueOf(c).Elem()
|
||||
cfgType := cfgValue.Type()
|
||||
|
||||
var res []AppConfigVariable
|
||||
res := make([]AppConfigVariable, 0, cfgType.NumField())
|
||||
|
||||
for i := range cfgType.NumField() {
|
||||
field := cfgType.Field(i)
|
||||
@@ -119,7 +118,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues
|
||||
return res
|
||||
}
|
||||
|
||||
func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
|
||||
func (c *AppConfig) FieldByKey(key string) (defaultValue string, err error) {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
@@ -128,36 +127,30 @@ func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool
|
||||
// Grab only the first part of the key, if there's a comma with additional properties
|
||||
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
|
||||
keyFromTag := tagValue[0]
|
||||
isInternal = slices.Contains(tagValue, "internal")
|
||||
if keyFromTag != key {
|
||||
continue
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
return valueField.String(), isInternal, nil
|
||||
return valueField.String(), nil
|
||||
}
|
||||
|
||||
// If we are here, the config key was not found
|
||||
return "", false, AppConfigKeyNotFoundError{field: key}
|
||||
return "", AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
|
||||
func (c *AppConfig) UpdateField(key string, value string) error {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
// Find the field in the struct whose "key" tag matches, then update that
|
||||
for i := range rt.NumField() {
|
||||
// Separate the key (before the comma) from any optional attributes after
|
||||
tagValue, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
|
||||
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
|
||||
if tagValue != key {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the field is internal and noInternal is true, we skip that
|
||||
if noInternal && attrs == "internal" {
|
||||
return AppConfigInternalForbiddenError{field: key}
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
if !valueField.CanSet() {
|
||||
return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key)
|
||||
@@ -184,20 +177,6 @@ func (e AppConfigKeyNotFoundError) Error() string {
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
|
||||
x := AppConfigKeyNotFoundError{}
|
||||
return errors.As(target, &x)
|
||||
}
|
||||
|
||||
type AppConfigInternalForbiddenError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Error() string {
|
||||
return "field '" + e.field + "' is internal and can't be updated"
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError
|
||||
x := AppConfigInternalForbiddenError{}
|
||||
return errors.As(target, &x)
|
||||
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"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/tracing"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type AppConfigService struct {
|
||||
dbConfig atomic.Pointer[model.AppConfig]
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
|
||||
defer tracing.End(span, err)
|
||||
|
||||
// We need to assign to the "err" variable, do not inline this into the "if"
|
||||
err = service.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetDbConfig returns the application configuration.
|
||||
// Important: Treat the object as read-only: do not modify its properties directly!
|
||||
func (s *AppConfigService) GetDbConfig() *model.AppConfig {
|
||||
v := s.dbConfig.Load()
|
||||
if v == nil {
|
||||
// This indicates a development-time error
|
||||
panic("called GetDbConfig before DbConfig is loaded")
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
// Values are the default ones
|
||||
return &model.AppConfig{
|
||||
// General
|
||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
|
||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Email
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "true"},
|
||||
SmtpHost: model.AppConfigVariable{},
|
||||
SmtpPort: model.AppConfigVariable{},
|
||||
SmtpFrom: model.AppConfigVariable{},
|
||||
SmtpUser: model.AppConfigVariable{},
|
||||
SmtpPassword: model.AppConfigVariable{},
|
||||
SmtpTls: model.AppConfigVariable{Value: "none"},
|
||||
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
|
||||
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
// LDAP
|
||||
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
||||
LdapUrl: model.AppConfigVariable{},
|
||||
LdapBindDn: model.AppConfigVariable{},
|
||||
LdapBindPassword: model.AppConfigVariable{},
|
||||
LdapBase: model.AppConfigVariable{},
|
||||
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
|
||||
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
|
||||
LdapSkipCertVerify: model.AppConfigVariable{Value: "false"},
|
||||
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{},
|
||||
LdapAttributeUserUsername: model.AppConfigVariable{},
|
||||
LdapAttributeUserEmail: model.AppConfigVariable{},
|
||||
LdapAttributeUserFirstName: model.AppConfigVariable{},
|
||||
LdapAttributeUserLastName: model.AppConfigVariable{},
|
||||
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
|
||||
LdapAttributeGroupName: model.AppConfigVariable{},
|
||||
LdapAdminGroupName: model.AppConfigVariable{},
|
||||
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) {
|
||||
// We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database
|
||||
// This works across multiple processes too
|
||||
tx = s.db.Begin()
|
||||
err = tx.Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin database transaction: %w", err)
|
||||
}
|
||||
|
||||
// With SQLite there's nothing else we need to do, because a transaction blocks the entire database
|
||||
// However, with Postgres we need to manually lock the table to prevent others from doing the same
|
||||
switch s.db.Name() {
|
||||
case "postgres":
|
||||
// We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled
|
||||
// Here we use a context with a 10s timeout in case the database is blocked for longer
|
||||
lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer lockCancel()
|
||||
err = tx.
|
||||
WithContext(lockCtx).
|
||||
Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE").
|
||||
Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err)
|
||||
}
|
||||
default:
|
||||
// Nothing to do here
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
// Perform an "upsert" if the key already exists, replacing the value
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).
|
||||
Create(&dbUpdate).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update config in database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Start the transaction
|
||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
|
||||
for field := range rt.Fields() {
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
// Skip values that are internal only and can't be updated
|
||||
if value == "" {
|
||||
// Ignore errors here as we know the key exists
|
||||
defaultValue, _, _ := defaultCfg.FieldByKey(key)
|
||||
err = cfg.UpdateField(key, defaultValue, true)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value, true)
|
||||
}
|
||||
|
||||
// If we tried to update an internal field, ignore the error (and do not update in the DB)
|
||||
if errors.Is(err, model.AppConfigInternalForbiddenError{}) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
// Return the updated config
|
||||
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateAppConfigValues updates the application configuration values in the database.
|
||||
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
// Start the transaction
|
||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
key := keysAndValues[i-1]
|
||||
value := keysAndValues[i]
|
||||
|
||||
// Ensure that the field is valid
|
||||
// We do this by grabbing the default value
|
||||
var defaultValue string
|
||||
defaultValue, isInternal, err := defaultCfg.FieldByKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
|
||||
}
|
||||
if !isInternal && common.EnvConfig.UiConfigDisabled {
|
||||
return &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
// Skip values that are internal only and can't be updated
|
||||
if value == "" {
|
||||
err = cfg.UpdateField(key, defaultValue, false)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value, false)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||
}
|
||||
|
||||
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
||||
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
||||
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.dbConfig.Store(dest)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||
// If the UI config is disabled, only load from the env
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
dest, err := s.loadDbConfigFromEnv(ctx, tx)
|
||||
return dest, err
|
||||
}
|
||||
|
||||
// First, start from the default configuration
|
||||
dest := s.getDefaultDbConfig()
|
||||
|
||||
// Load all configuration values from the database
|
||||
// This loads all values in a single shot
|
||||
var loaded []model.AppConfigVariable
|
||||
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer queryCancel()
|
||||
err := tx.
|
||||
WithContext(queryCtx).
|
||||
Find(&loaded).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through all values loaded from the database
|
||||
for _, v := range loaded {
|
||||
// Find the field in the struct whose "key" tag matches, then update that
|
||||
err = dest.UpdateField(v.Key, v.Value, false)
|
||||
|
||||
// We ignore the case of fields that don't exist, as there may be leftover data in the database
|
||||
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
|
||||
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||
// First, start from the default configuration
|
||||
dest := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through each field
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the key and internal tag values
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
|
||||
// Internal fields are loaded from the database as they can't be set from the environment
|
||||
if attrs == "internal" {
|
||||
var value string
|
||||
err := tx.WithContext(ctx).
|
||||
Model(&model.AppConfigVariable{}).
|
||||
Where("key = ?", key).
|
||||
Select("value").
|
||||
First(&value).Error
|
||||
if err == nil {
|
||||
rv.Field(i).FieldByName("Value").SetString(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||
|
||||
// Set the value if it's set
|
||||
value, ok := os.LookupEnv(envVarName)
|
||||
if ok {
|
||||
rv.Field(i).FieldByName("Value").SetString(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's sensitive, we also allow reading from file
|
||||
if attrs == "sensitive" {
|
||||
fileName := os.Getenv(envVarName + "_FILE")
|
||||
if fileName != "" {
|
||||
// #nosec G703 - Value is provided by admin
|
||||
b, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
|
||||
}
|
||||
rv.Field(i).FieldByName("Value").SetString(string(b))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||
func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
|
||||
service := &AppConfigService{
|
||||
dbConfig: atomic.Pointer[model.AppConfig]{},
|
||||
}
|
||||
service.dbConfig.Store(config)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func TestLoadDbConfig(t *testing.T) {
|
||||
t.Run("empty config table", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
// Load the config
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config should be equal to default config
|
||||
require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig())
|
||||
})
|
||||
|
||||
t.Run("loads value from config table", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Populate the config table with some initial values
|
||||
err := db.
|
||||
Create([]model.AppConfigVariable{
|
||||
// Overrides default value
|
||||
{Key: "appName", Value: "Test App"},
|
||||
{Key: "sessionDuration", Value: "5"},
|
||||
// Does not have a default value
|
||||
{Key: "smtpHost", Value: "example"},
|
||||
}).
|
||||
Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Values should match expected ones
|
||||
expect := service.getDefaultDbConfig()
|
||||
expect.AppName.Value = "Test App"
|
||||
expect.SessionDuration.Value = "5"
|
||||
expect.SmtpHost.Value = "example"
|
||||
require.Equal(t, service.GetDbConfig(), expect)
|
||||
})
|
||||
|
||||
t.Run("ignores unknown config keys", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Add an entry with a key that doesn't exist in the config struct
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "__nonExistentKey", Value: "some value"},
|
||||
{Key: "appName", Value: "TestApp"}, // This one should still be loaded
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
// This should not fail, just ignore the unknown key
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "TestApp", config.AppName.Value)
|
||||
})
|
||||
|
||||
t.Run("loading config multiple times", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Initial state
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "appName", Value: "InitialApp"},
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value)
|
||||
|
||||
// Update the database value
|
||||
err = db.Model(&model.AppConfigVariable{}).
|
||||
Where("key = ?", "appName").
|
||||
Update("value", "UpdatedApp").Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the config again, it should reflect the updated value
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value)
|
||||
})
|
||||
|
||||
t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) {
|
||||
// Save the original state and restore it after the test
|
||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||
defer func() {
|
||||
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
|
||||
}()
|
||||
|
||||
// Set environment variables for testing
|
||||
t.Setenv("APP_NAME", "EnvTest App")
|
||||
t.Setenv("SESSION_DURATION", "45")
|
||||
|
||||
// Enable UiConfigDisabled to load from env
|
||||
common.EnvConfig.UiConfigDisabled = true
|
||||
|
||||
// Create database with config that should be ignored
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "appName", Value: "DB App"},
|
||||
{Key: "sessionDuration", Value: "120"},
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
// Load the config
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config should be loaded from env, not DB
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env")
|
||||
require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env")
|
||||
})
|
||||
|
||||
t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) {
|
||||
// Save the original state and restore it after the test
|
||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||
defer func() {
|
||||
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
|
||||
}()
|
||||
|
||||
// Set environment variables that should be ignored
|
||||
t.Setenv("APP_NAME", "EnvTest App")
|
||||
t.Setenv("SESSION_DURATION", "45")
|
||||
|
||||
// Make sure UiConfigDisabled is false to load from DB
|
||||
common.EnvConfig.UiConfigDisabled = false
|
||||
|
||||
// Create database with config values that should take precedence
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "appName", Value: "DB App"},
|
||||
{Key: "sessionDuration", Value: "120"},
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
// Load the config
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config should be loaded from DB, not env
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env")
|
||||
require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAppConfigValues(t *testing.T) {
|
||||
t.Run("update single value", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update a single config value
|
||||
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify in-memory config was updated
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Test App", config.AppName.Value)
|
||||
|
||||
// Verify database was updated
|
||||
var dbValue model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&dbValue).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test App", dbValue.Value)
|
||||
})
|
||||
|
||||
t.Run("update multiple values", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update multiple config values
|
||||
err = service.UpdateAppConfigValues(
|
||||
t.Context(),
|
||||
"appName", "Test App",
|
||||
"sessionDuration", "30",
|
||||
"smtpHost", "mail.example.com",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify in-memory config was updated
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Test App", config.AppName.Value)
|
||||
require.Equal(t, "30", config.SessionDuration.Value)
|
||||
require.Equal(t, "mail.example.com", config.SmtpHost.Value)
|
||||
|
||||
// Verify database was updated
|
||||
var count int64
|
||||
db.Model(&model.AppConfigVariable{}).Count(&count)
|
||||
require.Equal(t, int64(3), count)
|
||||
|
||||
var appName, sessionDuration, smtpHost model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test App", appName.Value)
|
||||
|
||||
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "30", sessionDuration.Value)
|
||||
|
||||
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "mail.example.com", smtpHost.Value)
|
||||
})
|
||||
|
||||
t.Run("empty value resets to default", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// First change the value
|
||||
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value)
|
||||
|
||||
// Now set it to empty which should use default value
|
||||
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig
|
||||
})
|
||||
|
||||
t.Run("error with odd number of arguments", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update with odd number of arguments
|
||||
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid number of arguments")
|
||||
})
|
||||
|
||||
t.Run("error with invalid key", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update with invalid key
|
||||
err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid configuration key")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAppConfig(t *testing.T) {
|
||||
t.Run("updates configuration values from DTO", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create update DTO
|
||||
input := dto.AppConfigUpdateDto{
|
||||
AppName: "Updated App Name",
|
||||
SessionDuration: "120",
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: "587",
|
||||
}
|
||||
|
||||
// Update config
|
||||
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify returned updated variables
|
||||
require.NotEmpty(t, updatedVars)
|
||||
|
||||
var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool
|
||||
for _, v := range updatedVars {
|
||||
switch v.Key {
|
||||
case "appName":
|
||||
require.Equal(t, "Updated App Name", v.Value)
|
||||
foundAppName = true
|
||||
case "sessionDuration":
|
||||
require.Equal(t, "120", v.Value)
|
||||
foundSessionDuration = true
|
||||
case "smtpHost":
|
||||
require.Equal(t, "smtp.example.com", v.Value)
|
||||
foundSmtpHost = true
|
||||
case "smtpPort":
|
||||
require.Equal(t, "587", v.Value)
|
||||
foundSmtpPort = true
|
||||
}
|
||||
}
|
||||
require.True(t, foundAppName)
|
||||
require.True(t, foundSessionDuration)
|
||||
require.True(t, foundSmtpHost)
|
||||
require.True(t, foundSmtpPort)
|
||||
|
||||
// Verify in-memory config was updated
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Updated App Name", config.AppName.Value)
|
||||
require.Equal(t, "120", config.SessionDuration.Value)
|
||||
require.Equal(t, "smtp.example.com", config.SmtpHost.Value)
|
||||
require.Equal(t, "587", config.SmtpPort.Value)
|
||||
|
||||
// Verify database was updated
|
||||
var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Updated App Name", appName.Value)
|
||||
|
||||
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "120", sessionDuration.Value)
|
||||
|
||||
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "smtp.example.com", smtpHost.Value)
|
||||
|
||||
err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "587", smtpPort.Value)
|
||||
})
|
||||
|
||||
t.Run("empty values reset to defaults", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config and modify some values
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// First set some non-default values
|
||||
err = service.UpdateAppConfigValues(t.Context(),
|
||||
"appName", "Custom App",
|
||||
"sessionDuration", "120",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create update DTO with empty values to reset to defaults
|
||||
input := dto.AppConfigUpdateDto{
|
||||
AppName: "", // Should reset to default "Pocket ID"
|
||||
SessionDuration: "", // Should reset to default "60"
|
||||
}
|
||||
|
||||
// Update config
|
||||
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify returned updated variables (they should be empty strings in DB)
|
||||
var foundAppName, foundSessionDuration bool
|
||||
for _, v := range updatedVars {
|
||||
switch v.Key {
|
||||
case "appName":
|
||||
require.Equal(t, "Pocket ID", v.Value) // Returns the default value
|
||||
foundAppName = true
|
||||
case "sessionDuration":
|
||||
require.Equal(t, "60", v.Value) // Returns the default value
|
||||
foundSessionDuration = true
|
||||
}
|
||||
}
|
||||
require.True(t, foundAppName)
|
||||
require.True(t, foundSessionDuration)
|
||||
|
||||
// Verify in-memory config was reset to defaults
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Pocket ID", config.AppName.Value) // Default value
|
||||
require.Equal(t, "60", config.SessionDuration.Value) // Default value
|
||||
|
||||
// Verify database was updated with empty values
|
||||
for _, key := range []string{"appName", "sessionDuration"} {
|
||||
var loaded model.AppConfigVariable
|
||||
err = db.Where("key = ?", key).First(&loaded).Error
|
||||
require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key)
|
||||
require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
|
||||
// Save the original state and restore it after the test
|
||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||
defer func() {
|
||||
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
|
||||
}()
|
||||
|
||||
// Disable UI config
|
||||
common.EnvConfig.UiConfigDisabled = true
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update config
|
||||
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
||||
AppName: "Should Not Update",
|
||||
})
|
||||
|
||||
// Should get a UiConfigDisabledError
|
||||
require.Error(t, err)
|
||||
var uiConfigDisabledErr *common.UiConfigDisabledError
|
||||
require.ErrorAs(t, err, &uiConfigDisabledErr)
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
@@ -14,12 +15,12 @@ import (
|
||||
|
||||
type AuditLogService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
emailService *EmailService
|
||||
geoliteService *GeoLiteService
|
||||
}
|
||||
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *appconfig.AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||
return &AuditLogService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
@@ -64,7 +65,7 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
|
||||
}
|
||||
|
||||
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
||||
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
|
||||
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, dbConfig *appconfig.AppConfigModel) model.AuditLog {
|
||||
createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
|
||||
if !ok {
|
||||
// At this point the transaction has been canceled already, and error has been logged
|
||||
@@ -90,7 +91,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
}
|
||||
|
||||
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
|
||||
if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
|
||||
if dbConfig.EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
|
||||
go func() {
|
||||
// This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
|
||||
// We still want to have a context derived from the request's to carry over tracing info
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/ory/fosite/compose"
|
||||
fositejwt "github.com/ory/fosite/token/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
@@ -40,7 +41,7 @@ import (
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
ldapService *LdapService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *AppLockService
|
||||
@@ -54,7 +55,7 @@ const (
|
||||
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
|
||||
)
|
||||
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
|
||||
@@ -13,19 +13,20 @@ import (
|
||||
"github.com/italypaleale/go-kit/emailer"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
db *gorm.DB
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
}
|
||||
|
||||
func NewEmailService(db *gorm.DB, appConfigService *AppConfigService) (*EmailService, error) {
|
||||
func NewEmailService(db *gorm.DB, appConfigService *appconfig.AppConfigService) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
|
||||
@@ -54,12 +54,14 @@ func seedActorHostSchema(t *testing.T, db *gorm.DB) {
|
||||
func requireActorHostSchemaPreserved(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var tableRows int64
|
||||
require.NoError(t, db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error)
|
||||
err := db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), tableRows, "francis_ tables and their rows must be preserved by an import")
|
||||
|
||||
// The view is only valid if its backing table was preserved as well
|
||||
var viewCount int64
|
||||
require.NoError(t, db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error)
|
||||
err = db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), viewCount, "francis_ views must be preserved by an import")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
@@ -47,12 +48,12 @@ type JwtService struct {
|
||||
envConfig *common.EnvConfigSchema
|
||||
privateKey jwk.Key
|
||||
keyId string
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
instanceID string
|
||||
jwksEncoded []byte
|
||||
}
|
||||
|
||||
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService) (*JwtService, error) {
|
||||
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *appconfig.AppConfigService) (*JwtService, error) {
|
||||
service := &JwtService{}
|
||||
|
||||
err := service.init(ctx, db, instanceID, appConfigService, &common.EnvConfig)
|
||||
@@ -63,7 +64,7 @@ func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfi
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
|
||||
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
|
||||
s.appConfigService = appConfigService
|
||||
s.envConfig = envConfig
|
||||
s.db = db
|
||||
@@ -183,12 +184,11 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
|
||||
|
||||
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
||||
Expiration(now.Add(sessionDuration)).
|
||||
IssuedAt(now).
|
||||
Issuer(s.envConfig.AppURL).
|
||||
JwtID(uuid.New().String()).
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"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/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -35,7 +36,7 @@ func newTestEnvConfig() *common.EnvConfigSchema {
|
||||
}
|
||||
}
|
||||
|
||||
func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
|
||||
func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
|
||||
t.Helper()
|
||||
|
||||
service := &JwtService{}
|
||||
@@ -45,7 +46,7 @@ func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *App
|
||||
return service
|
||||
}
|
||||
|
||||
func setupJwtService(t *testing.T, instanceID string, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
|
||||
func setupJwtService(t *testing.T, instanceID string, appConfig *appconfig.AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -70,7 +71,7 @@ func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) {
|
||||
return testutils.NewDatabaseForTest(t), newTestEnvConfig()
|
||||
}
|
||||
|
||||
func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string {
|
||||
func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, key jwk.Key) string {
|
||||
t.Helper()
|
||||
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||
@@ -87,9 +88,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *
|
||||
}
|
||||
|
||||
func TestJwtService_Init(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
mockConfig := appconfig.NewTestAppConfigService(nil)
|
||||
|
||||
t.Run("should generate new key when none exists", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -192,9 +191,7 @@ func TestJwtService_Init(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
mockConfig := appconfig.NewTestAppConfigService(nil)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
mockEnvConfig := newTestEnvConfig()
|
||||
instanceID := newInstanceID(t, db)
|
||||
@@ -310,9 +307,8 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
const sessionDuration = time.Hour
|
||||
mockConfig := appconfig.NewTestAppConfigService(nil)
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
instanceID := newInstanceID(t, db)
|
||||
|
||||
@@ -325,7 +321,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -366,7 +362,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(adminUser, "")
|
||||
tokenString, err := service.GenerateAccessToken(adminUser, "", sessionDuration)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
@@ -389,7 +385,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{ID: "user-with-auth-method"},
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
|
||||
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant, sessionDuration)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
@@ -400,29 +396,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
|
||||
})
|
||||
|
||||
t.Run("uses session duration from config", func(t *testing.T) {
|
||||
customMockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
|
||||
})
|
||||
service, _, _ := setupJwtService(t, instanceID, customMockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user456"},
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
expectedExp := time.Now().Add(30 * time.Minute)
|
||||
expiration, ok := claims.Expiration()
|
||||
assert.True(t, ok, "Expiration not found in token")
|
||||
timeDiff := expectedExp.Sub(expiration).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes")
|
||||
})
|
||||
|
||||
t.Run("works with Ed25519 keys", func(t *testing.T) {
|
||||
origKeyID := createEdDSAKeyJWK(t, db, instanceID, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
|
||||
@@ -437,7 +410,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
require.NoError(t, err, "Failed to generate access token with Ed25519 key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -475,7 +448,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
require.NoError(t, err, "Failed to generate access token with ECDSA key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -513,7 +486,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
require.NoError(t, err, "Failed to generate access token with RSA key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -582,7 +555,7 @@ func TestTokenTypeValidator(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
|
||||
func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, privateKeyRaw any) string {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
|
||||
@@ -597,7 +570,7 @@ var (
|
||||
rsaKeyPrecomputeOnce sync.Once
|
||||
)
|
||||
|
||||
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
|
||||
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
rsaKeyPrecomputeOnce.Do(func() {
|
||||
@@ -612,7 +585,7 @@ func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig
|
||||
return importKey(t, db, instanceID, envConfig, appConfig, rsaKeyPrecomputed)
|
||||
}
|
||||
|
||||
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
|
||||
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new P-256 ECDSA key
|
||||
@@ -624,7 +597,7 @@ func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *
|
||||
}
|
||||
|
||||
// Helper function to create an Ed25519 key and save it as JWK
|
||||
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
|
||||
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new Ed25519 key pair
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
@@ -31,11 +32,11 @@ import (
|
||||
type LdapService struct {
|
||||
db *gorm.DB
|
||||
httpClient *http.Client
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
userService *UserService
|
||||
groupService *UserGroupService
|
||||
fileStorage storage.FileStorage
|
||||
clientFactory func() (ldapClient, error)
|
||||
clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error)
|
||||
}
|
||||
|
||||
type savePicture struct {
|
||||
@@ -69,7 +70,7 @@ type ldapClient interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *appconfig.AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||
service := &LdapService{
|
||||
db: db,
|
||||
httpClient: httpClient,
|
||||
@@ -83,15 +84,13 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *LdapService) createClient() (ldapClient, error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
return nil, fmt.Errorf("LDAP is not enabled")
|
||||
}
|
||||
|
||||
// Setup LDAP connection
|
||||
client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{
|
||||
client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
|
||||
}))
|
||||
if err != nil {
|
||||
@@ -99,7 +98,7 @@ func (s *LdapService) createClient() (ldapClient, error) {
|
||||
}
|
||||
|
||||
// Bind as service account
|
||||
err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value)
|
||||
err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
|
||||
}
|
||||
@@ -107,15 +106,20 @@ func (s *LdapService) createClient() (ldapClient, error) {
|
||||
}
|
||||
|
||||
func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
dbConfig, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
// Setup LDAP connection
|
||||
client, err := s.clientFactory()
|
||||
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)
|
||||
desiredState, err := s.fetchDesiredState(ctx, client, dbConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch LDAP state: %w", err)
|
||||
}
|
||||
@@ -128,7 +132,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
defer tx.Rollback()
|
||||
|
||||
// Reconcile users
|
||||
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
|
||||
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users: %w", err)
|
||||
}
|
||||
@@ -167,7 +171,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
|
||||
func (s *LdapService) 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)
|
||||
if err != nil {
|
||||
@@ -175,7 +179,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
|
||||
}
|
||||
|
||||
// Then fetch groups to complete the desired LDAP state snapshot
|
||||
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
|
||||
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig)
|
||||
if err != nil {
|
||||
return ldapDesiredState{}, err
|
||||
}
|
||||
@@ -183,7 +187,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
|
||||
// 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)
|
||||
s.applyAdminGroupMembership(users, groups, dbConfig)
|
||||
|
||||
return ldapDesiredState{
|
||||
users: users,
|
||||
@@ -193,15 +197,14 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
if dbConfig.LdapAdminGroupName.Value == "" {
|
||||
func (s *LdapService) 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 != dbConfig.LdapAdminGroupName.Value {
|
||||
if group.input.Name != string(dbConfig.LdapAdminGroupName) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -216,21 +219,19 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
func (s *LdapService) 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.Value,
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||
dbConfig.LdapAttributeGroupMember.Value,
|
||||
dbConfig.LdapAttributeGroupName.String(),
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.String(),
|
||||
dbConfig.LdapAttributeGroupMember.String(),
|
||||
}
|
||||
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
dbConfig.LdapBase.Value,
|
||||
dbConfig.LdapBase.String(),
|
||||
ldap.ScopeWholeSubtree,
|
||||
0, 0, 0, false,
|
||||
dbConfig.LdapUserGroupSearchFilter.Value,
|
||||
dbConfig.LdapUserGroupSearchFilter.String(),
|
||||
searchAttrs,
|
||||
[]ldap.Control{},
|
||||
)
|
||||
@@ -244,21 +245,21 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
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.Value))
|
||||
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.Value)
|
||||
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.String())
|
||||
memberUsernames := make([]string, 0, len(groupMembers))
|
||||
for _, member := range groupMembers {
|
||||
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
|
||||
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.String())
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
@@ -267,8 +268,8 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
}
|
||||
|
||||
syncGroup := dto.UserGroupCreateDto{
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
|
||||
LdapID: ldapID,
|
||||
}
|
||||
dto.Normalize(&syncGroup)
|
||||
@@ -290,27 +291,30 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
dbConfig, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
// Query LDAP for all users we want to manage
|
||||
searchAttrs := []string{
|
||||
"sn",
|
||||
"cn",
|
||||
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
|
||||
dbConfig.LdapAttributeUserUsername.Value,
|
||||
dbConfig.LdapAttributeUserEmail.Value,
|
||||
dbConfig.LdapAttributeUserFirstName.Value,
|
||||
dbConfig.LdapAttributeUserLastName.Value,
|
||||
dbConfig.LdapAttributeUserProfilePicture.Value,
|
||||
dbConfig.LdapAttributeUserDisplayName.Value,
|
||||
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.Value,
|
||||
dbConfig.LdapBase.String(),
|
||||
ldap.ScopeWholeSubtree,
|
||||
0, 0, 0, false,
|
||||
dbConfig.LdapUserSearchFilter.Value,
|
||||
dbConfig.LdapUserSearchFilter.String(),
|
||||
searchAttrs,
|
||||
[]ldap.Control{},
|
||||
)
|
||||
@@ -326,28 +330,28 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
|
||||
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.Value))
|
||||
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.Value))
|
||||
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.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())),
|
||||
EmailVerified: true,
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
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,
|
||||
@@ -369,16 +373,14 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
desiredUsers = append(desiredUsers, ldapDesiredUser{
|
||||
ldapID: ldapID,
|
||||
input: newUser,
|
||||
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
|
||||
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()),
|
||||
})
|
||||
}
|
||||
|
||||
return desiredUsers, ldapUserIDs, usernamesByDN, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
func (s *LdapService) 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 != "" {
|
||||
@@ -386,14 +388,15 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
}
|
||||
|
||||
// Then try to extract the username directly from the DN
|
||||
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||
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
|
||||
if _, err := ldap.ParseDN(member); err != nil {
|
||||
_, err := ldap.ParseDN(member)
|
||||
if err != nil {
|
||||
return norm.NFC.String(member)
|
||||
}
|
||||
|
||||
@@ -403,7 +406,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
ldap.ScopeBaseObject,
|
||||
0, 0, 0, false,
|
||||
"(objectClass=*)",
|
||||
[]string{dbConfig.LdapAttributeUserUsername.Value},
|
||||
[]string{usernameAttr},
|
||||
[]ldap.Control{},
|
||||
)
|
||||
|
||||
@@ -413,7 +416,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
return ""
|
||||
}
|
||||
|
||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||
username = userResult.Entries[0].GetAttributeValue(usernameAttr)
|
||||
if username == "" {
|
||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||
return ""
|
||||
@@ -498,9 +501,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{}) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
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) {
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -144,8 +145,8 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
|
||||
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
||||
appCfg := defaultTestLDAPAppConfig()
|
||||
appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"}
|
||||
appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"}
|
||||
appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)"
|
||||
appCfg.LdapAttributeGroupMember = "memberUid"
|
||||
|
||||
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
@@ -237,7 +238,7 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
|
||||
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appConfig *model.AppConfig
|
||||
appConfig *appconfig.AppConfigModel
|
||||
groupEntry *ldap.Entry
|
||||
groupName string
|
||||
groupLookup string
|
||||
@@ -255,10 +256,10 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "configured group name attribute differs from DN RDN",
|
||||
appConfig: func() *model.AppConfig {
|
||||
appConfig: func() *appconfig.AppConfigModel {
|
||||
cfg := defaultTestLDAPAppConfig()
|
||||
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
|
||||
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
|
||||
cfg.LdapAttributeGroupName = "displayName"
|
||||
cfg.LdapAdminGroupName = "pocketid.admin"
|
||||
return cfg
|
||||
}(),
|
||||
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
@@ -308,7 +309,7 @@ func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB
|
||||
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
|
||||
}
|
||||
|
||||
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.AppConfigModel, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -316,7 +317,7 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
appConfig := NewTestAppConfigService(appConfigModel)
|
||||
appConfig := appconfig.NewTestAppConfigService(appConfigModel)
|
||||
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
userService := NewUserService(
|
||||
@@ -332,32 +333,32 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
|
||||
)
|
||||
|
||||
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
|
||||
service.clientFactory = func() (ldapClient, error) {
|
||||
service.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return service, db
|
||||
}
|
||||
|
||||
func defaultTestLDAPAppConfig() *model.AppConfig {
|
||||
return &model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
LdapEnabled: model.AppConfigVariable{Value: "true"},
|
||||
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
|
||||
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
|
||||
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
|
||||
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
|
||||
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
|
||||
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
|
||||
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
|
||||
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
|
||||
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
|
||||
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -39,7 +40,7 @@ const (
|
||||
type OidcService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
previewBuilder oidcClientPreviewBuilder
|
||||
scimService *ScimService
|
||||
|
||||
@@ -54,7 +55,7 @@ type oidcClientPreviewBuilder interface {
|
||||
func NewOidcService(
|
||||
db *gorm.DB,
|
||||
jwtService *JwtService,
|
||||
appConfigService *AppConfigService,
|
||||
appConfigService *appconfig.AppConfigService,
|
||||
previewBuilder oidcClientPreviewBuilder,
|
||||
scimService *ScimService,
|
||||
httpClient *http.Client,
|
||||
|
||||
@@ -3,11 +3,13 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -20,13 +22,13 @@ import (
|
||||
type OneTimeAccessService struct {
|
||||
db *gorm.DB
|
||||
userService *UserService
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
|
||||
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *appconfig.AppConfigService) *OneTimeAccessService {
|
||||
return &OneTimeAccessService{
|
||||
db: db,
|
||||
userService: userService,
|
||||
@@ -38,23 +40,31 @@ func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
|
||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
|
||||
if isDisabled {
|
||||
dbConfig, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
|
||||
_, err = s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
|
||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
|
||||
if isDisabled {
|
||||
dbConfig, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
|
||||
return "", &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
var userId string
|
||||
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
||||
err = s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Do not return error if user not found to prevent email enumeration
|
||||
return "", nil
|
||||
@@ -151,7 +161,7 @@ func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, use
|
||||
// Commit
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("error committing transaction: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
@@ -172,13 +182,18 @@ func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Cont
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
||||
dbConfig, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
err := tx.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
|
||||
Preload("User").
|
||||
@@ -198,7 +213,11 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword)
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(
|
||||
oneTimeAccessToken.User,
|
||||
AuthenticationMethodOneTimePassword,
|
||||
dbConfig.SessionDuration.AsDurationMinutes(),
|
||||
)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -211,11 +230,16 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
|
||||
s.auditLogService.Create(
|
||||
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
|
||||
ipAddress, userAgent,
|
||||
oneTimeAccessToken.User.ID, model.AuditLogData{},
|
||||
tx,
|
||||
)
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
return model.User{}, "", fmt.Errorf("error committing transaction: %w", err)
|
||||
}
|
||||
|
||||
return oneTimeAccessToken.User, accessToken, nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
|
||||
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig())
|
||||
appConfig := appconfig.NewTestAppConfigService(nil)
|
||||
instanceID := newInstanceID(t, db)
|
||||
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
|
||||
auditLogService := NewAuditLogService(db, appConfig, nil, &GeoLiteService{})
|
||||
|
||||
@@ -3,8 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -17,10 +19,10 @@ import (
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *appconfig.AppConfigService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
|
||||
}
|
||||
|
||||
@@ -63,13 +65,18 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var group model.UserGroup
|
||||
err := tx.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
First(&group).
|
||||
@@ -79,7 +86,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
|
||||
if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
if group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
@@ -122,10 +129,9 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
Preload("Users").
|
||||
Create(&group).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
}
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
} else if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
@@ -161,8 +167,13 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
// Disallow updating the group if it is an LDAP group and LDAP is enabled
|
||||
if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
if !isLdapSync && group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
|
||||
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
@@ -216,7 +227,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
||||
// Fetch the users based on the userIds
|
||||
var users []model.User
|
||||
if len(userIds) > 0 {
|
||||
err := tx.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("id IN (?)", userIds).
|
||||
Find(&users).
|
||||
|
||||
@@ -13,16 +13,17 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||
)
|
||||
|
||||
@@ -31,14 +32,14 @@ type UserService struct {
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
appImagesService *AppImagesService
|
||||
scimService *ScimService
|
||||
fileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *appconfig.AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
@@ -204,9 +205,13 @@ func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDe
|
||||
}
|
||||
|
||||
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error {
|
||||
var user model.User
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
err := tx.
|
||||
var user model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", userID).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
@@ -217,7 +222,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
}
|
||||
|
||||
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
|
||||
if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
if !allowLdapDelete && !user.Disabled && user.LdapID != nil && cfg.LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserUpdateError{}
|
||||
}
|
||||
|
||||
@@ -253,7 +258,12 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil {
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return model.User{}, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
@@ -285,7 +295,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
|
||||
user.LdapID = &input.LdapID
|
||||
}
|
||||
|
||||
err := tx.WithContext(ctx).Create(&user).Error
|
||||
err = tx.WithContext(ctx).Create(&user).Error
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
// Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
|
||||
if !isLdapSync {
|
||||
@@ -304,12 +314,14 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
if !isLdapSync {
|
||||
if len(input.UserGroupIds) == 0 {
|
||||
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
||||
err = s.applyDefaultGroups(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
|
||||
err = s.applyDefaultCustomClaims(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
@@ -322,12 +334,15 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var groupIDs []string
|
||||
v := config.SignupDefaultUserGroupIDs.Value
|
||||
v := cfg.SignupDefaultUserGroupIDs
|
||||
if v != "" && v != "[]" {
|
||||
err := json.Unmarshal([]byte(v), &groupIDs)
|
||||
err = json.Unmarshal([]byte(v), &groupIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
|
||||
}
|
||||
@@ -354,12 +369,15 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var claims []dto.CustomClaimCreateDto
|
||||
v := config.SignupDefaultCustomClaims.Value
|
||||
v := cfg.SignupDefaultCustomClaims
|
||||
if v != "" && v != "[]" {
|
||||
err := json.Unmarshal([]byte(v), &claims)
|
||||
err = json.Unmarshal([]byte(v), &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
|
||||
}
|
||||
@@ -394,12 +412,17 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser
|
||||
}
|
||||
|
||||
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil {
|
||||
cfg, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return model.User{}, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err := tx.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", userID).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
@@ -410,8 +433,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
}
|
||||
|
||||
// Check if this is an LDAP user and LDAP is enabled
|
||||
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
|
||||
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
|
||||
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
|
||||
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
|
||||
|
||||
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
|
||||
// Restricted update: Only locale can be changed when:
|
||||
@@ -429,7 +452,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
|
||||
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
|
||||
// Email has changed, reset email verification status
|
||||
user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
|
||||
user.EmailVerified = cfg.EmailsVerified.IsTrue()
|
||||
}
|
||||
|
||||
user.Email = updatedUser.Email
|
||||
|
||||
@@ -16,8 +16,8 @@ type handler struct {
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newHandler(service *Service, appConfig AppConfigProvider) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
func (h *handler) beginRegistration(c *gin.Context) {
|
||||
|
||||
@@ -8,18 +8,19 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
GenerateAccessToken(user model.User, authenticationMethod string) (string, error)
|
||||
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error)
|
||||
VerifyAccessToken(tokenString string) (jwt.Token, error)
|
||||
GetAuthenticationMethod(token jwt.Token) (string, error)
|
||||
}
|
||||
|
||||
type AuditLogger interface {
|
||||
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
|
||||
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog
|
||||
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, dbConfig *appconfig.AppConfigModel) model.AuditLog
|
||||
}
|
||||
|
||||
type AppConfigProvider interface {
|
||||
@@ -30,9 +31,8 @@ type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
AppURL string
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
AppConfig AppConfigProvider
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
@@ -48,7 +48,7 @@ func New(deps Dependencies) (*Module, error) {
|
||||
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service, deps.AppConfig),
|
||||
handler: newHandler(service),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -24,11 +25,10 @@ import (
|
||||
const authenticationMethodPhishingResistant = "phr"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
webAuthn *gowebauthn.WebAuthn
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
appConfig AppConfigProvider
|
||||
db *gorm.DB
|
||||
webAuthn *gowebauthn.WebAuthn
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) (*Service, error) {
|
||||
@@ -57,11 +57,10 @@ func newService(deps Dependencies) (*Service, error) {
|
||||
}
|
||||
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
webAuthn: wa,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
appConfig: deps.AppConfig,
|
||||
db: deps.DB,
|
||||
webAuthn: wa,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -228,6 +227,11 @@ func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOp
|
||||
}
|
||||
|
||||
func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
||||
dbConfig, err := appconfig.FromCtx(ctx)
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -235,7 +239,7 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA
|
||||
|
||||
// Load & delete the session row
|
||||
var storedSession WebauthnSession
|
||||
err := tx.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(&storedSession, "id = ?", sessionID).
|
||||
@@ -275,7 +279,7 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx)
|
||||
s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx, dbConfig)
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Recreate the standalone config table with the same schema it had before it was frozen
|
||||
CREATE TABLE app_config_variables
|
||||
(
|
||||
key VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Populate it from the frozen JSON document stored in the "kv" table
|
||||
-- json_each expands the JSON object back into one row per key/value pair.
|
||||
INSERT INTO app_config_variables (key, value)
|
||||
SELECT je.key, je.value
|
||||
FROM kv, json_each_text(kv."value"::json) AS je(key, value)
|
||||
WHERE kv."key" = 'config_migrated';
|
||||
|
||||
-- Remove the frozen config from the "kv" table
|
||||
DELETE FROM kv WHERE "key" = 'config_migrated';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Freeze the app configuration
|
||||
-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key
|
||||
--
|
||||
-- json_object_agg aggregates all rows into a JSON object
|
||||
-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty
|
||||
INSERT INTO kv ("key", "value")
|
||||
SELECT 'config_migrated', json_object_agg("key", "value")::text
|
||||
FROM app_config_variables
|
||||
HAVING count(*) > 0;
|
||||
|
||||
-- Drop the now-frozen standalone config table
|
||||
DROP TABLE app_config_variables;
|
||||
@@ -0,0 +1,22 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Recreate the standalone config table with the same schema it had before it was frozen
|
||||
CREATE TABLE app_config_variables
|
||||
(
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Populate it from the frozen JSON document stored in the "kv" table
|
||||
-- json_each expands the JSON object back into one row per key/value pair.
|
||||
INSERT INTO app_config_variables ("key", "value")
|
||||
SELECT je.key, je.value
|
||||
FROM kv, json_each(kv."value") AS je
|
||||
WHERE kv."key" = 'config_migrated';
|
||||
|
||||
-- Remove the frozen config from the "kv" table
|
||||
DELETE FROM kv WHERE "key" = 'config_migrated';
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,18 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Freeze the app configuration
|
||||
-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key
|
||||
--
|
||||
-- json_group_object aggregates all rows into a JSON object
|
||||
-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty
|
||||
INSERT INTO kv ("key", "value")
|
||||
SELECT 'config_migrated', json_group_object("key", "value")
|
||||
FROM app_config_variables
|
||||
HAVING count(*) > 0;
|
||||
|
||||
-- Drop the now-frozen standalone config table
|
||||
DROP TABLE app_config_variables;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
Reference in New Issue
Block a user