mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-18 00:49:04 +02:00
feat!: replace custom claims with custom fields
This commit is contained in:
@@ -131,7 +131,6 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
|
||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
||||
|
||||
@@ -13,25 +13,25 @@ import (
|
||||
)
|
||||
|
||||
type services struct {
|
||||
appConfigService *service.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
auditLogService *service.AuditLogService
|
||||
jwtService *service.JwtService
|
||||
webauthnService *service.WebAuthnService
|
||||
scimService *service.ScimService
|
||||
userService *service.UserService
|
||||
customClaimService *service.CustomClaimService
|
||||
oidcService *service.OidcService
|
||||
userGroupService *service.UserGroupService
|
||||
ldapService *service.LdapService
|
||||
apiKeyService *service.ApiKeyService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
userSignUpService *service.UserSignUpService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
appConfigService *service.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
auditLogService *service.AuditLogService
|
||||
jwtService *service.JwtService
|
||||
webauthnService *service.WebAuthnService
|
||||
scimService *service.ScimService
|
||||
userService *service.UserService
|
||||
customFieldValueService *service.CustomFieldValueService
|
||||
oidcService *service.OidcService
|
||||
userGroupService *service.UserGroupService
|
||||
ldapService *service.LdapService
|
||||
apiKeyService *service.ApiKeyService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
userSignUpService *service.UserSignUpService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -59,7 +59,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||
}
|
||||
|
||||
svc.customClaimService = service.NewCustomClaimService(db)
|
||||
svc.customFieldValueService = service.NewCustomFieldValueService(db, svc.appConfigService)
|
||||
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
||||
@@ -67,13 +67,13 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
|
||||
svc.scimService = service.NewScimService(db, scheduler, httpClient)
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customFieldValueService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||
}
|
||||
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.customFieldValueService, svc.scimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customFieldValueService, svc.appImagesService, svc.scimService, fileStorage)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||
|
||||
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
|
||||
|
||||
@@ -171,23 +171,30 @@ type MissingSessionIdError struct{}
|
||||
func (e MissingSessionIdError) Error() string { return "Missing session id" }
|
||||
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ReservedClaimError struct {
|
||||
type ReservedCustomFieldError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e ReservedClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||
func (e ReservedCustomFieldError) Error() string {
|
||||
return fmt.Sprintf("Custom field %s is reserved and can't be used", e.Key)
|
||||
}
|
||||
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e ReservedCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DuplicateClaimError struct {
|
||||
type DuplicateCustomFieldError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e DuplicateClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||
func (e DuplicateCustomFieldError) Error() string {
|
||||
return fmt.Sprintf("Custom field %s is already defined", e.Key)
|
||||
}
|
||||
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e DuplicateCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type CustomFieldValidationError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e CustomFieldValidationError) Error() string { return e.Message }
|
||||
func (e CustomFieldValidationError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInvalidCodeVerifierError struct{}
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
// NewCustomClaimController creates a new controller for custom claim management
|
||||
// @Summary Custom claim management controller
|
||||
// @Description Initializes all custom claim-related API endpoints
|
||||
// @Tags Custom Claims
|
||||
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||
|
||||
customClaimsGroup := group.Group("/custom-claims")
|
||||
customClaimsGroup.Use(authMiddleware.Add())
|
||||
{
|
||||
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
|
||||
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
|
||||
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||
}
|
||||
}
|
||||
|
||||
type CustomClaimController struct {
|
||||
customClaimService *service.CustomClaimService
|
||||
}
|
||||
|
||||
// getSuggestionsHandler godoc
|
||||
// @Summary Get custom claim suggestions
|
||||
// @Description Get a list of suggested custom claim names
|
||||
// @Tags Custom Claims
|
||||
// @Produce json
|
||||
// @Success 200 {array} string "List of suggested custom claim names"
|
||||
// @Router /api/custom-claims/suggestions [get]
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserHandler godoc
|
||||
// @Summary Update custom claims for a user
|
||||
// @Description Update or create custom claims for a specific user
|
||||
// @Tags Custom Claims
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userId path string true "User ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /api/custom-claims/user/{userId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroupHandler godoc
|
||||
// @Summary Update custom claims for a user group
|
||||
// @Description Update or create custom claims for a specific user group
|
||||
// @Tags Custom Claims
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userGroupId path string true "User Group ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userGroupId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type AppConfigUpdateDto struct {
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||
CustomFields string `json:"customFields" binding:"omitempty,json"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package dto
|
||||
|
||||
type CustomClaimDto struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type CustomClaimCreateDto struct {
|
||||
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||
Value string `json:"value" binding:"required" unorm:"nfc"`
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dto
|
||||
|
||||
type CustomFieldValueDto struct {
|
||||
CustomFieldID string `json:"customFieldId"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type CustomFieldValueCreateDto struct {
|
||||
CustomFieldID string `json:"customFieldId" binding:"required_without=Key" unorm:"nfc"`
|
||||
Key string `json:"key,omitempty" unorm:"nfc"`
|
||||
Value string `json:"value" unorm:"nfc"`
|
||||
}
|
||||
|
||||
type CustomFieldType string
|
||||
|
||||
const (
|
||||
CustomFieldTypeString CustomFieldType = "string"
|
||||
CustomFieldTypeNumber CustomFieldType = "number"
|
||||
CustomFieldTypeBoolean CustomFieldType = "boolean"
|
||||
)
|
||||
|
||||
type CustomFieldTarget string
|
||||
|
||||
const (
|
||||
CustomFieldTargetUser CustomFieldTarget = "user"
|
||||
CustomFieldTargetGroup CustomFieldTarget = "group"
|
||||
CustomFieldTargetBoth CustomFieldTarget = "both"
|
||||
)
|
||||
|
||||
type CustomFieldDto struct {
|
||||
ID string `json:"id" binding:"required,uuid"`
|
||||
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"required" unorm:"nfc"`
|
||||
Type CustomFieldType `json:"type" binding:"required,oneof=string number boolean"`
|
||||
Target CustomFieldTarget `json:"target" binding:"required,oneof=user group both"`
|
||||
Required bool `json:"required"`
|
||||
UserEditable bool `json:"userEditable"`
|
||||
DefaultValue string `json:"defaultValue" unorm:"nfc"`
|
||||
ValidationRegex string `json:"validationRegex" binding:"regex" unorm:"nfc"`
|
||||
ValidationErrorMessage string `json:"validationErrorMessage" unorm:"nfc"`
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package dto
|
||||
|
||||
type SignUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||
}
|
||||
|
||||
@@ -7,33 +7,34 @@ import (
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
Disabled bool `json:"disabled"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
|
||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
UserGroupIds []string `json:"userGroupIds"`
|
||||
LdapID string `json:"-"`
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
UserGroupIds []string `json:"userGroupIds"`
|
||||
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (u UserCreateDto) Validate() error {
|
||||
|
||||
@@ -11,7 +11,7 @@ type UserGroupDto struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
Users []UserDto `json:"users"`
|
||||
@@ -22,7 +22,6 @@ type UserGroupMinimalDto struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserCount int64 `json:"userCount"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
@@ -33,9 +32,10 @@ type UserGroupUpdateAllowedOidcClientsDto struct {
|
||||
}
|
||||
|
||||
type UserGroupCreateDto struct {
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||
LdapID string `json:"-"`
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (g UserGroupCreateDto) Validate() error {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
@@ -33,6 +35,12 @@ func init() {
|
||||
"client_id": func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
},
|
||||
"regex": func(fl validator.FieldLevel) bool {
|
||||
return ValidateRegex(fl.Field().String())
|
||||
},
|
||||
"uuid": func(fl validator.FieldLevel) bool {
|
||||
return ValidateUUID(fl.Field().String())
|
||||
},
|
||||
"ttl": func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
@@ -59,6 +67,16 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateStruct(input any) error {
|
||||
e, ok := binding.Validator.Engine().(interface {
|
||||
Struct(any) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("validator does not implement the expected interface")
|
||||
}
|
||||
return e.Struct(input)
|
||||
}
|
||||
|
||||
// ValidateUsername validates username inputs
|
||||
func ValidateUsername(username string) bool {
|
||||
return validateUsernameRegex.MatchString(username)
|
||||
@@ -69,6 +87,20 @@ func ValidateClientID(clientID string) bool {
|
||||
return validateClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// ValidateRegex validates that the input is either empty or a compilable regular expression.
|
||||
func ValidateRegex(value string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
_, err := regexp.Compile(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ValidateUUID validates UUID inputs.
|
||||
func ValidateUUID(value string) bool {
|
||||
return uuid.Validate(value) == nil
|
||||
}
|
||||
|
||||
// ValidateCallbackURL validates the input callback URL
|
||||
func ValidateCallbackURL(str string) bool {
|
||||
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
|
||||
|
||||
@@ -58,6 +58,42 @@ func TestValidateClientID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"valid", "^EMP-[0-9]+$", true},
|
||||
{"invalid", "[", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateRegex(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUUID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid", "89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03", true},
|
||||
{"invalid", "field-1", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateUUID(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -37,7 +37,9 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
|
||||
require.NoError(t, err)
|
||||
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
|
||||
customFieldsValueService := service.NewCustomFieldValueService(db, appConfigService)
|
||||
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, customFieldsValueService, nil, nil, nil)
|
||||
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ type AppConfig struct {
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||
CustomFields AppConfigVariable `key:"customFields,public"` // Public
|
||||
// Internal
|
||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||
// Email
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package model
|
||||
|
||||
type CustomClaim struct {
|
||||
Base
|
||||
|
||||
Key string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type CustomFieldValue struct {
|
||||
Base
|
||||
|
||||
CustomFieldID string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
@@ -26,9 +26,9 @@ type User struct {
|
||||
Disabled bool `sortable:"true" filterable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
CustomFieldValues []CustomFieldValue
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
|
||||
@@ -13,7 +13,7 @@ type UserGroup struct {
|
||||
LdapID *string
|
||||
UpdatedAt *datatype.DateTime
|
||||
Users []User `gorm:"many2many:user_groups_users;"`
|
||||
CustomClaims []CustomClaim
|
||||
CustomFieldValues []CustomFieldValue
|
||||
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -67,7 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||
CustomFields: model.AppConfigVariable{Value: "[]"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Internal
|
||||
InstanceID: model.AppConfigVariable{Value: ""},
|
||||
@@ -158,6 +159,87 @@ func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) normalizeCustomFieldConfigUpdate(ctx context.Context, tx *gorm.DB, oldValue, newValue string) (string, error) {
|
||||
if newValue == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
oldFields, err := ParseCustomFieldDefinitions(oldValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newFields, err := ParseCustomFieldDefinitions(newValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oldFieldsByID := make(map[string]dto.CustomFieldDto, len(oldFields))
|
||||
for _, oldField := range oldFields {
|
||||
oldFieldsByID[oldField.ID] = oldField
|
||||
}
|
||||
|
||||
newFieldsByID := make(map[string]dto.CustomFieldDto, len(newFields))
|
||||
for _, newField := range newFields {
|
||||
newFieldsByID[newField.ID] = newField
|
||||
oldField, ok := oldFieldsByID[newField.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if oldField.Type != newField.Type {
|
||||
return "", &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s type can't be changed", oldField.Key)}
|
||||
}
|
||||
|
||||
// If the field existed before, but the appliesTo was changed so that it no longer applies to users/groups,
|
||||
// then we need to delete the corresponding values on users/groups
|
||||
for _, idType := range []idType{UserID, UserGroupID} {
|
||||
oldApplies := customFieldAppliesTo(oldField, idType)
|
||||
newApplies := customFieldAppliesTo(newField, idType)
|
||||
if oldApplies && !newApplies {
|
||||
if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any fields were removed, and if so delete the corresponding values on users/groups
|
||||
for _, oldField := range oldFields {
|
||||
if _, ok := newFieldsByID[oldField.ID]; ok {
|
||||
continue
|
||||
}
|
||||
for _, idType := range []idType{UserID, UserGroupID} {
|
||||
if !customFieldAppliesTo(oldField, idType) {
|
||||
continue
|
||||
}
|
||||
if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizedValue, err := json.Marshal(newFields)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to normalize custom fields JSON: %w", err)
|
||||
}
|
||||
return string(normalizedValue), nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) deleteCustomFieldValuesForFieldID(ctx context.Context, tx *gorm.DB, idType idType, customFieldID string) error {
|
||||
query := tx.WithContext(ctx).Model(&model.CustomFieldValue{})
|
||||
switch idType {
|
||||
case UserID:
|
||||
query = query.Where("user_id IS NOT NULL")
|
||||
case UserGroupID:
|
||||
query = query.Where("user_group_id IS NOT NULL")
|
||||
}
|
||||
|
||||
if err := query.Where("custom_field_id = ?", customFieldID).Delete(&model.CustomFieldValue{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete custom field values for field %s: %w", customFieldID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
@@ -179,6 +261,11 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
|
||||
return nil, fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
input.CustomFields, err = s.normalizeCustomFieldConfigUpdate(ctx, tx, cfg.CustomFields.Value, input.CustomFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defaultCfg := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -243,7 +244,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
||||
// Verify database was updated
|
||||
var count int64
|
||||
db.Model(&model.AppConfigVariable{}).Count(&count)
|
||||
require.Equal(t, int64(3), count)
|
||||
require.GreaterOrEqual(t, count, int64(3))
|
||||
|
||||
var appName, sessionDuration, smtpHost model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||
@@ -470,4 +471,98 @@ func TestUpdateAppConfig(t *testing.T) {
|
||||
var uiConfigDisabledErr *common.UiConfigDisabledError
|
||||
require.ErrorAs(t, err, &uiConfigDisabledErr)
|
||||
})
|
||||
|
||||
t.Run("keeps custom field values when custom field key changes", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fieldID := "d20db690-c6fb-4b5b-8288-ac68eb80c6f4"
|
||||
oldCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
|
||||
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
user := model.User{Username: "test-user"}
|
||||
err = db.Create(&user).Error
|
||||
require.NoError(t, err)
|
||||
err = db.Create(&model.CustomFieldValue{
|
||||
CustomFieldID: fieldID,
|
||||
Value: "Engineering",
|
||||
UserID: &user.ID,
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{db: db}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
newCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"team","displayName":"Team","type":"string","target":"user","required":false}]`
|
||||
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
||||
CustomFields: newCustomFields,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var customFieldValue model.CustomFieldValue
|
||||
err = db.Where("user_id = ?", user.ID).First(&customFieldValue).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fieldID, customFieldValue.CustomFieldID)
|
||||
require.Equal(t, "Engineering", customFieldValue.Value)
|
||||
|
||||
var storedConfig model.AppConfigVariable
|
||||
err = db.Where("key = ?", "customFields").First(&storedConfig).Error
|
||||
require.NoError(t, err)
|
||||
var storedFields []dto.CustomFieldDto
|
||||
err = json.Unmarshal([]byte(storedConfig.Value), &storedFields)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, storedFields, 1)
|
||||
require.Equal(t, fieldID, storedFields[0].ID)
|
||||
require.Equal(t, "team", storedFields[0].Key)
|
||||
})
|
||||
|
||||
t.Run("rejects custom field type changes", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
oldCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
|
||||
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{db: db}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
newCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"number","target":"user","required":false}]`
|
||||
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
||||
CustomFields: newCustomFields,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "type can't be changed")
|
||||
})
|
||||
|
||||
t.Run("deletes custom field values when custom field is removed", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fieldID := "a99f05e6-57a0-468d-a8fe-98bd637cbf98"
|
||||
oldCustomFields := `[{"id":"a99f05e6-57a0-468d-a8fe-98bd637cbf98","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
|
||||
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
user := model.User{Username: "test-user"}
|
||||
err = db.Create(&user).Error
|
||||
require.NoError(t, err)
|
||||
err = db.Create(&model.CustomFieldValue{
|
||||
CustomFieldID: fieldID,
|
||||
Value: "Engineering",
|
||||
UserID: &user.ID,
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{db: db}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
||||
CustomFields: "[]",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var count int64
|
||||
err = db.Model(&model.CustomFieldValue{}).Where("user_id = ?", user.ID).Count(&count).Error
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomClaimService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
|
||||
return &CustomClaimService{db: db}
|
||||
}
|
||||
|
||||
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
|
||||
func isReservedClaim(key string) bool {
|
||||
switch key {
|
||||
case "given_name",
|
||||
"family_name",
|
||||
"name",
|
||||
"email",
|
||||
"email_verified",
|
||||
"preferred_username",
|
||||
"display_name",
|
||||
"groups",
|
||||
TokenTypeClaim,
|
||||
"sub",
|
||||
"iss",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"auth_time",
|
||||
"nonce",
|
||||
"acr",
|
||||
"amr",
|
||||
"azp",
|
||||
"nbf",
|
||||
"jti":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// idType is the type of the id used to identify the user or user group
|
||||
type idType string
|
||||
|
||||
const (
|
||||
UserID idType = "user_id"
|
||||
UserGroupID idType = "user_group_id"
|
||||
)
|
||||
|
||||
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
|
||||
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||
// Check for duplicate keys in the claims slice
|
||||
seenKeys := make(map[string]struct{})
|
||||
for _, claim := range claims {
|
||||
if _, ok := seenKeys[claim.Key]; ok {
|
||||
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||
}
|
||||
seenKeys[claim.Key] = struct{}{}
|
||||
}
|
||||
|
||||
var existingClaims []model.CustomClaim
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType), value).
|
||||
Find(&existingClaims).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete claims that are not in the new list
|
||||
for _, existingClaim := range existingClaims {
|
||||
found := false
|
||||
for _, claim := range claims {
|
||||
if claim.Key == existingClaim.Key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Delete(&existingClaim).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update claims
|
||||
for _, claim := range claims {
|
||||
if isReservedClaim(claim.Key) {
|
||||
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||
}
|
||||
customClaim := model.CustomClaim{
|
||||
Key: claim.Key,
|
||||
Value: claim.Value,
|
||||
}
|
||||
|
||||
switch idType {
|
||||
case UserID:
|
||||
customClaim.UserID = &value
|
||||
case UserGroupID:
|
||||
customClaim.UserGroupID = &value
|
||||
}
|
||||
|
||||
// Update the claim if it already exists or create a new one
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType)+" = ? AND key = ?", value, claim.Key).
|
||||
Assign(&customClaim).
|
||||
FirstOrCreate(&model.CustomClaim{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the updated claims
|
||||
var updatedClaims []model.CustomClaim
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType)+" = ?", value).
|
||||
Find(&updatedClaims).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
func (s *CustomClaimService) GetCustomClaimsForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||
var customClaims []model.CustomClaim
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Find(&customClaims).
|
||||
Error
|
||||
return customClaims, err
|
||||
}
|
||||
|
||||
func (s *CustomClaimService) GetCustomClaimsForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||
var customClaims []model.CustomClaim
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("user_group_id = ?", userGroupID).
|
||||
Find(&customClaims).
|
||||
Error
|
||||
return customClaims, err
|
||||
}
|
||||
|
||||
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
|
||||
// prioritizing the user's claims over user group claims with the same key.
|
||||
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||
// Get the custom claims of the user
|
||||
customClaims, err := s.GetCustomClaimsForUser(ctx, userID, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store user's claims in a map to prioritize and prevent duplicates
|
||||
claimsMap := make(map[string]model.CustomClaim)
|
||||
for _, claim := range customClaims {
|
||||
claimsMap[claim.Key] = claim
|
||||
}
|
||||
|
||||
// Get all user groups of the user
|
||||
var userGroupsOfUser []model.UserGroup
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Preload("CustomClaims").
|
||||
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||
Where("user_groups_users.user_id = ?", userID).
|
||||
Find(&userGroupsOfUser).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add only non-duplicate custom claims from user groups
|
||||
for _, userGroup := range userGroupsOfUser {
|
||||
for _, groupClaim := range userGroup.CustomClaims {
|
||||
// Only add claim if it does not exist in the user's claims
|
||||
if _, exists := claimsMap[groupClaim.Key]; !exists {
|
||||
claimsMap[groupClaim.Key] = groupClaim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the claimsMap back to a slice
|
||||
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
|
||||
for _, claim := range claimsMap {
|
||||
finalClaims = append(finalClaims, claim)
|
||||
}
|
||||
|
||||
return finalClaims, nil
|
||||
}
|
||||
|
||||
// GetSuggestions returns a list of custom claim keys that have been used before
|
||||
func (s *CustomClaimService) GetSuggestions(ctx context.Context) ([]string, error) {
|
||||
var customClaimsKeys []string
|
||||
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Model(&model.CustomClaim{}).
|
||||
Group("key").
|
||||
Order("COUNT(*) DESC").
|
||||
Pluck("key", &customClaimsKeys).Error
|
||||
|
||||
return customClaimsKeys, err
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomFieldValueService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewCustomFieldValueService(db *gorm.DB, appConfigService *AppConfigService) *CustomFieldValueService {
|
||||
return &CustomFieldValueService{db: db, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
func customFieldAppliesTo(field dto.CustomFieldDto, idType idType) bool {
|
||||
switch field.Target {
|
||||
case dto.CustomFieldTargetBoth:
|
||||
return true
|
||||
case dto.CustomFieldTargetUser:
|
||||
return idType == UserID
|
||||
case dto.CustomFieldTargetGroup:
|
||||
return idType == UserGroupID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isReservedOIDCClaim checks if a key is reserved by standard OIDC claims, e.g. email or preferred_username.
|
||||
func isReservedOIDCClaim(key string) bool {
|
||||
switch key {
|
||||
case "given_name",
|
||||
"family_name",
|
||||
"name",
|
||||
"email",
|
||||
"email_verified",
|
||||
"preferred_username",
|
||||
"display_name",
|
||||
"groups",
|
||||
TokenTypeClaim,
|
||||
"sub",
|
||||
"iss",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"auth_time",
|
||||
"nonce",
|
||||
"acr",
|
||||
"amr",
|
||||
"azp",
|
||||
"nbf",
|
||||
"jti":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// idType is the type of the id used to identify the user or user group
|
||||
type idType string
|
||||
|
||||
const (
|
||||
UserID idType = "user_id"
|
||||
UserGroupID idType = "user_group_id"
|
||||
)
|
||||
|
||||
// UpdateCustomFieldValuesForUser updates the custom field values for a user.
|
||||
func (s *CustomFieldValueService) UpdateCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserID, userID, customFieldValues, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedCustomFieldValues, nil
|
||||
}
|
||||
|
||||
// updateSelfEditableCustomFieldValuesForUser updates only the custom fields a user is allowed to edit themselves.
|
||||
func (s *CustomFieldValueService) updateSelfEditableCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
|
||||
fields, err := s.GetConfiguredCustomFieldsForTarget(UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
editableFields := make([]dto.CustomFieldDto, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if !field.UserEditable {
|
||||
continue
|
||||
}
|
||||
editableFields = append(editableFields, field)
|
||||
}
|
||||
|
||||
return s.updateCustomFieldValuesForFields(ctx, UserID, userID, customFieldValues, editableFields, tx)
|
||||
}
|
||||
|
||||
func (s *CustomFieldValueService) updateCustomFieldValuesForFields(ctx context.Context, idType idType, ownerID string, customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
|
||||
normalizedCustomFieldValues, err := validateCustomFieldValuesAgainstFields(customFieldValues, fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fieldIDs := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
fieldIDs = append(fieldIDs, field.ID)
|
||||
}
|
||||
|
||||
valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(normalizedCustomFieldValues))
|
||||
fieldIDsToKeep := make([]string, 0, len(normalizedCustomFieldValues))
|
||||
for _, customFieldValue := range normalizedCustomFieldValues {
|
||||
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
|
||||
fieldIDsToKeep = append(fieldIDsToKeep, customFieldValue.CustomFieldID)
|
||||
}
|
||||
|
||||
if len(fieldIDs) > 0 {
|
||||
deleteQuery := tx.WithContext(ctx).
|
||||
Where(string(idType)+" = ? AND custom_field_id IN ?", ownerID, fieldIDs)
|
||||
if len(fieldIDsToKeep) > 0 {
|
||||
deleteQuery = deleteQuery.Where("custom_field_id NOT IN ?", fieldIDsToKeep)
|
||||
}
|
||||
if err := deleteQuery.Delete(&model.CustomFieldValue{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, customFieldValue := range valuesByFieldID {
|
||||
value := model.CustomFieldValue{
|
||||
CustomFieldID: customFieldValue.CustomFieldID,
|
||||
Value: customFieldValue.Value,
|
||||
}
|
||||
switch idType {
|
||||
case UserID:
|
||||
value.UserID = &ownerID
|
||||
case UserGroupID:
|
||||
value.UserGroupID = &ownerID
|
||||
}
|
||||
if err := tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType)+" = ? AND custom_field_id = ?", ownerID, customFieldValue.CustomFieldID).
|
||||
Assign(&value).
|
||||
FirstOrCreate(&model.CustomFieldValue{}).
|
||||
Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
switch idType {
|
||||
case UserID:
|
||||
return s.GetCustomFieldValuesForUser(ctx, ownerID, tx)
|
||||
case UserGroupID:
|
||||
return s.GetCustomFieldValuesForUserGroup(ctx, ownerID, tx)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCustomFieldValuesForUserGroup updates the custom field values for a user group.
|
||||
func (s *CustomFieldValueService) UpdateCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserGroupID, userGroupID, customFieldValues, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedCustomFieldValues, nil
|
||||
}
|
||||
|
||||
// updateCustomFieldValuesInternal updates the custom field values for a user or user group within a transaction.
|
||||
func (s *CustomFieldValueService) updateCustomFieldValuesInternal(ctx context.Context, idType idType, value string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
|
||||
fields, err := s.GetConfiguredCustomFieldsForTarget(idType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customFieldValues, err = validateCustomFieldValuesAgainstFields(customFieldValues, fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var existingCustomFieldValues []model.CustomFieldValue
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType), value).
|
||||
Find(&existingCustomFieldValues).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete values that are not in the new list.
|
||||
for _, existingCustomFieldValue := range existingCustomFieldValues {
|
||||
found := false
|
||||
for _, customFieldValue := range customFieldValues {
|
||||
if customFieldValue.CustomFieldID == existingCustomFieldValue.CustomFieldID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Delete(&existingCustomFieldValue).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update custom field values.
|
||||
for _, inputCustomFieldValue := range customFieldValues {
|
||||
customFieldValue := model.CustomFieldValue{
|
||||
CustomFieldID: inputCustomFieldValue.CustomFieldID,
|
||||
Value: inputCustomFieldValue.Value,
|
||||
}
|
||||
|
||||
switch idType {
|
||||
case UserID:
|
||||
customFieldValue.UserID = &value
|
||||
case UserGroupID:
|
||||
customFieldValue.UserGroupID = &value
|
||||
}
|
||||
|
||||
// Update the value if it already exists or create a new one.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType)+" = ? AND custom_field_id = ?", value, inputCustomFieldValue.CustomFieldID).
|
||||
Assign(&customFieldValue).
|
||||
FirstOrCreate(&model.CustomFieldValue{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the updated custom field values.
|
||||
var updatedCustomFieldValues []model.CustomFieldValue
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType)+" = ?", value).
|
||||
Find(&updatedCustomFieldValues).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedCustomFieldValues, nil
|
||||
}
|
||||
|
||||
func (s *CustomFieldValueService) GetCustomFieldValuesForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
|
||||
var customFieldValues []model.CustomFieldValue
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Find(&customFieldValues).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.applyDefaultCustomFieldValues(UserID, userID, customFieldValues)
|
||||
}
|
||||
|
||||
func (s *CustomFieldValueService) GetCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
|
||||
var customFieldValues []model.CustomFieldValue
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("user_group_id = ?", userGroupID).
|
||||
Find(&customFieldValues).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.applyDefaultCustomFieldValues(UserGroupID, userGroupID, customFieldValues)
|
||||
}
|
||||
|
||||
// GetCustomFieldValuesForUserWithUserGroups returns the custom field values of a user and all user groups the user is a member of,
|
||||
// prioritizing the user's values over user group values for the same custom field.
|
||||
func (s *CustomFieldValueService) GetCustomFieldValuesForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
|
||||
customFieldValues, err := s.GetCustomFieldValuesForUser(ctx, userID, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
valuesByFieldID := make(map[string]model.CustomFieldValue)
|
||||
for _, customFieldValue := range customFieldValues {
|
||||
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
|
||||
}
|
||||
|
||||
// Get all user groups of the user
|
||||
var userGroupsOfUser []model.UserGroup
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Preload("CustomFieldValues").
|
||||
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||
Where("user_groups_users.user_id = ?", userID).
|
||||
Find(&userGroupsOfUser).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add only non-duplicate custom fields from user groups
|
||||
for _, userGroup := range userGroupsOfUser {
|
||||
groupCustomFieldValues, err := s.applyDefaultCustomFieldValues(UserGroupID, userGroup.ID, userGroup.CustomFieldValues)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, groupCustomFieldValue := range groupCustomFieldValues {
|
||||
if _, exists := valuesByFieldID[groupCustomFieldValue.CustomFieldID]; !exists {
|
||||
valuesByFieldID[groupCustomFieldValue.CustomFieldID] = groupCustomFieldValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalCustomFieldValues := make([]model.CustomFieldValue, 0, len(valuesByFieldID))
|
||||
for _, customFieldValue := range valuesByFieldID {
|
||||
finalCustomFieldValues = append(finalCustomFieldValues, customFieldValue)
|
||||
}
|
||||
|
||||
return finalCustomFieldValues, nil
|
||||
}
|
||||
|
||||
func (s *CustomFieldValueService) applyDefaultCustomFieldValues(idType idType, ownerID string, customFieldValues []model.CustomFieldValue) ([]model.CustomFieldValue, error) {
|
||||
fields, err := s.GetConfiguredCustomFieldsForTarget(idType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
valuesByFieldID := make(map[string]struct{}, len(customFieldValues))
|
||||
for _, customFieldValue := range customFieldValues {
|
||||
valuesByFieldID[customFieldValue.CustomFieldID] = struct{}{}
|
||||
}
|
||||
|
||||
effectiveCustomFieldValues := append([]model.CustomFieldValue{}, customFieldValues...)
|
||||
for _, field := range fields {
|
||||
if field.DefaultValue == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := valuesByFieldID[field.ID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
customFieldValue := model.CustomFieldValue{
|
||||
CustomFieldID: field.ID,
|
||||
Value: field.DefaultValue,
|
||||
}
|
||||
switch idType {
|
||||
case UserID:
|
||||
customFieldValue.UserID = &ownerID
|
||||
case UserGroupID:
|
||||
customFieldValue.UserGroupID = &ownerID
|
||||
}
|
||||
effectiveCustomFieldValues = append(effectiveCustomFieldValues, customFieldValue)
|
||||
}
|
||||
|
||||
return effectiveCustomFieldValues, nil
|
||||
}
|
||||
|
||||
func (s *CustomFieldValueService) GetConfiguredCustomFieldsForTarget(idType idType) ([]dto.CustomFieldDto, error) {
|
||||
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredFields := make([]dto.CustomFieldDto, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if customFieldAppliesTo(field, idType) {
|
||||
filteredFields = append(filteredFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredFields, nil
|
||||
}
|
||||
|
||||
func ParseCustomFieldDefinitions(value string) ([]dto.CustomFieldDto, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var fields []dto.CustomFieldDto
|
||||
if err := json.Unmarshal([]byte(value), &fields); err != nil {
|
||||
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("invalid custom fields JSON: %v", err)}
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]struct{}, len(fields))
|
||||
seenKeys := make(map[string]struct{}, len(fields))
|
||||
for i, field := range fields {
|
||||
field.Key = strings.TrimSpace(field.Key)
|
||||
fields[i].Key = field.Key
|
||||
|
||||
if err := dto.ValidateStruct(field); err != nil {
|
||||
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is invalid: %v", field.Key, err)}
|
||||
}
|
||||
|
||||
if _, ok := seenIDs[field.ID]; ok {
|
||||
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field id %s is already defined", field.ID)}
|
||||
}
|
||||
seenIDs[field.ID] = struct{}{}
|
||||
if isReservedOIDCClaim(field.Key) {
|
||||
return nil, &common.ReservedCustomFieldError{Key: field.Key}
|
||||
}
|
||||
if _, ok := seenKeys[field.Key]; ok {
|
||||
return nil, &common.DuplicateCustomFieldError{Key: field.Key}
|
||||
}
|
||||
seenKeys[field.Key] = struct{}{}
|
||||
|
||||
if field.ValidationRegex != "" {
|
||||
if field.Type != dto.CustomFieldTypeString {
|
||||
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s can only use regex validation for text values", field.Key)}
|
||||
}
|
||||
}
|
||||
if field.Required && field.DefaultValue == "" {
|
||||
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s requires a default value", field.Key)}
|
||||
}
|
||||
if field.DefaultValue != "" {
|
||||
if err := validateCustomFieldValue(dto.CustomFieldValueCreateDto{CustomFieldID: field.ID, Value: field.DefaultValue}, field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func validateCustomFieldValuesAgainstFields(customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto) ([]dto.CustomFieldValueCreateDto, error) {
|
||||
fieldsByID := make(map[string]dto.CustomFieldDto, len(fields))
|
||||
for _, field := range fields {
|
||||
fieldsByID[field.ID] = field
|
||||
}
|
||||
|
||||
valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(customFieldValues))
|
||||
for _, customFieldValue := range customFieldValues {
|
||||
field, ok := fieldsByID[customFieldValue.CustomFieldID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
customFieldValue.CustomFieldID = field.ID
|
||||
customFieldValue.Key = field.Key
|
||||
|
||||
if _, ok := valuesByFieldID[customFieldValue.CustomFieldID]; ok {
|
||||
return nil, &common.DuplicateCustomFieldError{Key: field.Key}
|
||||
}
|
||||
|
||||
if field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" && !field.Required {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := validateCustomFieldValue(customFieldValue, field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
|
||||
}
|
||||
|
||||
normalizedCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(valuesByFieldID))
|
||||
for _, field := range fields {
|
||||
customFieldValue, ok := valuesByFieldID[field.ID]
|
||||
if ok {
|
||||
normalizedCustomFieldValues = append(normalizedCustomFieldValues, customFieldValue)
|
||||
continue
|
||||
}
|
||||
|
||||
if field.DefaultValue != "" {
|
||||
normalizedCustomFieldValues = append(normalizedCustomFieldValues, dto.CustomFieldValueCreateDto{
|
||||
CustomFieldID: field.ID,
|
||||
Key: field.Key,
|
||||
Value: field.DefaultValue,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Required {
|
||||
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedCustomFieldValues, nil
|
||||
}
|
||||
|
||||
func validateCustomFieldValue(customFieldValue dto.CustomFieldValueCreateDto, field dto.CustomFieldDto) error {
|
||||
if field.Required && field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" {
|
||||
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)}
|
||||
}
|
||||
|
||||
switch field.Type {
|
||||
case dto.CustomFieldTypeString:
|
||||
if field.ValidationRegex != "" {
|
||||
matches, err := regexp.MatchString(field.ValidationRegex, customFieldValue.Value)
|
||||
if err != nil {
|
||||
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s has invalid validation regex: %v", field.Key, err)}
|
||||
}
|
||||
if !matches {
|
||||
if field.ValidationErrorMessage != "" {
|
||||
return &common.CustomFieldValidationError{Message: field.ValidationErrorMessage}
|
||||
}
|
||||
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s does not match the required format", field.Key)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case dto.CustomFieldTypeNumber:
|
||||
if _, err := strconv.ParseFloat(customFieldValue.Value, 64); err != nil {
|
||||
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a number", field.Key)}
|
||||
}
|
||||
case dto.CustomFieldTypeBoolean:
|
||||
if _, err := strconv.ParseBool(customFieldValue.Value); err != nil {
|
||||
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a boolean", field.Key)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func customFieldValueTokenValue(customFieldValue model.CustomFieldValue, field *dto.CustomFieldDto) (any, error) {
|
||||
if field != nil {
|
||||
switch field.Type {
|
||||
case dto.CustomFieldTypeString:
|
||||
return customFieldValue.Value, nil
|
||||
case dto.CustomFieldTypeNumber:
|
||||
value, err := strconv.ParseFloat(customFieldValue.Value, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
case dto.CustomFieldTypeBoolean:
|
||||
value, err := strconv.ParseBool(customFieldValue.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
var jsonValue any
|
||||
if err := json.Unmarshal([]byte(customFieldValue.Value), &jsonValue); err == nil {
|
||||
return jsonValue, nil
|
||||
}
|
||||
|
||||
return customFieldValue.Value, nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseCustomFieldDefinitionsValidatesRegex(t *testing.T) {
|
||||
_, err := ParseCustomFieldDefinitions(`[{"id":"89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03","key":"department","displayName":"Department","type":"string","target":"user","required":false,"validationRegex":"["}]`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid validation regex")
|
||||
|
||||
_, err = ParseCustomFieldDefinitions(`[{"id":"353555d9-7de8-4320-a10f-5ca4c122a363","key":"age","displayName":"Age","type":"number","target":"user","required":false,"validationRegex":"^[0-9]+$"}]`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "can only use regex validation for text values")
|
||||
|
||||
_, err = ParseCustomFieldDefinitions(`[{"id":"fe2bc740-6193-4ef2-b1e6-2408a691a98c","key":"department","displayName":"Department","type":"string","target":"user","required":true}]`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "requires a default value")
|
||||
|
||||
_, err = ParseCustomFieldDefinitions(`[{"id":"be42096c-3dc0-4a9c-8074-086b9f866286","key":"department","displayName":"Department","type":"string","target":"user","required":true,"validationRegex":"^ENG-[0-9]+$","defaultValue":"Sales"}]`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not match the required format")
|
||||
}
|
||||
|
||||
func TestParseCustomFieldDefinitionsValidatesKey(t *testing.T) {
|
||||
fields, err := ParseCustomFieldDefinitions(`[{"id":"36c1e786-c9e9-4daf-ab51-502ab8efc9ea","key":" department ","displayName":"Department","type":"string","target":"user","required":false}]`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fields, 1)
|
||||
assert.Equal(t, "department", fields[0].Key)
|
||||
|
||||
_, err = ParseCustomFieldDefinitions(`[{"id":"c0e41fb3-59c7-488a-8edb-57e94e9f15ac","key":" ","displayName":"Empty","type":"string","target":"user","required":false}]`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "custom field key is required")
|
||||
|
||||
_, err = ParseCustomFieldDefinitions(`[{"id":"8b2ff8eb-bcf5-4866-b690-1a5b6f9da56c","key":"email","displayName":"Email","type":"string","target":"user","required":false}]`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reserved")
|
||||
}
|
||||
|
||||
func TestValidateCustomFieldValuesAgainstFieldsAppliesRegex(t *testing.T) {
|
||||
fields := []dto.CustomFieldDto{
|
||||
{
|
||||
ID: "4ca0513e-e223-4900-8c5e-303acac4d021",
|
||||
Key: "employee_id",
|
||||
DisplayName: "Employee ID",
|
||||
Type: dto.CustomFieldTypeString,
|
||||
ValidationRegex: "^EMP-[0-9]+$",
|
||||
ValidationErrorMessage: "Employee ID must start with EMP-",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{
|
||||
{CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "INVALID"},
|
||||
}, fields)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "Employee ID must start with EMP-", err.Error())
|
||||
|
||||
values, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{
|
||||
{CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "EMP-123"},
|
||||
}, fields)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 1)
|
||||
assert.Equal(t, "4ca0513e-e223-4900-8c5e-303acac4d021", values[0].CustomFieldID)
|
||||
assert.Equal(t, "EMP-123", values[0].Value)
|
||||
}
|
||||
|
||||
func TestValidateCustomFieldValuesAgainstFieldsUsesDefaultValue(t *testing.T) {
|
||||
fields := []dto.CustomFieldDto{
|
||||
{
|
||||
ID: "4225f448-f189-47d5-97d6-90292cc5bf9e",
|
||||
Key: "department",
|
||||
DisplayName: "Department",
|
||||
Type: dto.CustomFieldTypeString,
|
||||
Required: true,
|
||||
DefaultValue: "Engineering",
|
||||
},
|
||||
{
|
||||
ID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579",
|
||||
Key: "active",
|
||||
DisplayName: "Active",
|
||||
Type: dto.CustomFieldTypeBoolean,
|
||||
Required: true,
|
||||
DefaultValue: "false",
|
||||
},
|
||||
}
|
||||
|
||||
values, err := validateCustomFieldValuesAgainstFields(nil, fields)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 2)
|
||||
assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "4225f448-f189-47d5-97d6-90292cc5bf9e", Key: "department", Value: "Engineering"}, values[0])
|
||||
assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579", Key: "active", Value: "false"}, values[1])
|
||||
}
|
||||
|
||||
func TestGetCustomFieldValuesAppliesDefaultValues(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
group := model.UserGroup{Name: "engineering", FriendlyName: "Engineering"}
|
||||
require.NoError(t, db.Create(&group).Error)
|
||||
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
|
||||
|
||||
appConfigService := NewTestAppConfigService(&model.AppConfig{
|
||||
CustomFields: model.AppConfigVariable{Value: `[
|
||||
{"id":"81b3c82a-46c8-49c0-9559-a31df8586ef1","key":"department","displayName":"Department","type":"string","target":"user","required":false,"defaultValue":"Engineering"},
|
||||
{"id":"b064d601-bc94-4ecf-a5cb-b783f3de0281","key":"group_label","displayName":"Group label","type":"string","target":"group","required":false,"defaultValue":"Employee"}
|
||||
]`},
|
||||
})
|
||||
service := NewCustomFieldValueService(db, appConfigService)
|
||||
|
||||
userValues, err := service.GetCustomFieldValuesForUser(t.Context(), user.ID, db)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, userValues, 1)
|
||||
assert.Equal(t, "81b3c82a-46c8-49c0-9559-a31df8586ef1", userValues[0].CustomFieldID)
|
||||
assert.Equal(t, "Engineering", userValues[0].Value)
|
||||
require.NotNil(t, userValues[0].UserID)
|
||||
|
||||
groupValues, err := service.GetCustomFieldValuesForUserGroup(t.Context(), group.ID, db)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groupValues, 1)
|
||||
assert.Equal(t, "b064d601-bc94-4ecf-a5cb-b783f3de0281", groupValues[0].CustomFieldID)
|
||||
assert.Equal(t, "Employee", groupValues[0].Value)
|
||||
require.NotNil(t, groupValues[0].UserGroupID)
|
||||
|
||||
combinedValues, err := service.GetCustomFieldValuesForUserWithUserGroups(t.Context(), user.ID, db)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, combinedValues, 2)
|
||||
valuesByFieldID := map[string]string{}
|
||||
for _, value := range combinedValues {
|
||||
valuesByFieldID[value.CustomFieldID] = value.Value
|
||||
}
|
||||
assert.Equal(t, "Engineering", valuesByFieldID["81b3c82a-46c8-49c0-9559-a31df8586ef1"])
|
||||
assert.Equal(t, "Employee", valuesByFieldID["b064d601-bc94-4ecf-a5cb-b783f3de0281"])
|
||||
}
|
||||
|
||||
func TestUpdateSelfEditableCustomFieldValuesForUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
require.NoError(t, db.Create([]model.CustomFieldValue{
|
||||
{UserID: &user.ID, CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "old"},
|
||||
{UserID: &user.ID, CustomFieldID: "8501e000-09bb-428c-8be3-b0d3b0c682fd", Value: "admin"},
|
||||
}).Error)
|
||||
|
||||
appConfigService := NewTestAppConfigService(&model.AppConfig{
|
||||
CustomFields: model.AppConfigVariable{Value: `[
|
||||
{"id":"608a9c35-2330-433d-bf33-c46f065d5d06","key":"nickname","displayName":"Nickname","type":"string","target":"user","required":false,"userEditable":true},
|
||||
{"id":"8501e000-09bb-428c-8be3-b0d3b0c682fd","key":"cost_center","displayName":"Cost center","type":"string","target":"user","required":false,"userEditable":false}
|
||||
]`},
|
||||
})
|
||||
service := NewCustomFieldValueService(db, appConfigService)
|
||||
|
||||
tx := db.Begin()
|
||||
updatedValues, err := service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{
|
||||
{CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "new"},
|
||||
}, tx)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.Commit().Error)
|
||||
|
||||
valuesByFieldID := map[string]string{}
|
||||
for _, value := range updatedValues {
|
||||
valuesByFieldID[value.CustomFieldID] = value.Value
|
||||
}
|
||||
assert.Equal(t, "new", valuesByFieldID["608a9c35-2330-433d-bf33-c46f065d5d06"])
|
||||
assert.Equal(t, "admin", valuesByFieldID["8501e000-09bb-428c-8be3-b0d3b0c682fd"])
|
||||
|
||||
tx = db.Begin()
|
||||
_, err = service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{
|
||||
{CustomFieldID: "invalid", Value: "user"},
|
||||
}, tx)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not configured")
|
||||
tx.Rollback()
|
||||
|
||||
var costCenter model.CustomFieldValue
|
||||
require.NoError(t, db.Where("user_id = ? AND custom_field_id = ?", user.ID, "8501e000-09bb-428c-8be3-b0d3b0c682fd").First(&costCenter).Error)
|
||||
assert.Equal(t, "admin", costCenter.Value)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -563,6 +565,56 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add custom fields
|
||||
customFields := []dto.CustomFieldDto{
|
||||
{
|
||||
ID: "189356b1-57f3-4c14-bd59-3ae1132a36d1",
|
||||
Key: "department",
|
||||
Type: "string",
|
||||
UserEditable: true,
|
||||
DisplayName: "Department",
|
||||
Target: "user",
|
||||
ValidationRegex: "^[A-Za-z]+$",
|
||||
ValidationErrorMessage: "Department must only contain letters",
|
||||
},
|
||||
{
|
||||
ID: "0b68d19a-bb72-4750-84b4-2f0992f5200c",
|
||||
Key: "nickname",
|
||||
Type: "string",
|
||||
UserEditable: true,
|
||||
Required: true,
|
||||
DisplayName: "Nickname",
|
||||
Target: "user",
|
||||
DefaultValue: "to-remove",
|
||||
},
|
||||
{
|
||||
ID: "8d081fd8-6a51-45a1-8051-04c3b043f5bd",
|
||||
Key: "elevatedRights",
|
||||
Type: "boolean",
|
||||
DisplayName: "Elevated Rights",
|
||||
Target: "group",
|
||||
},
|
||||
{
|
||||
ID: "3d7c6054-e146-48cb-b2d3-7d7897dcbc51",
|
||||
Key: "internalId",
|
||||
Type: "number",
|
||||
DefaultValue: "0",
|
||||
UserEditable: false,
|
||||
DisplayName: "Internal ID",
|
||||
Target: "both",
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
|
||||
customFieldsJSON, err := json.Marshal(&customFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.appConfigService.UpdateAppConfigValues(ctx, "customFields", string(customFieldsJSON))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the app config from the database after resetting the values
|
||||
err = s.appConfigService.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -216,8 +216,69 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LdapService) getLDAPCustomFields(idType idType) ([]dto.CustomFieldDto, error) {
|
||||
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ldapFields := make([]dto.CustomFieldDto, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if !customFieldAppliesTo(field, idType) {
|
||||
continue
|
||||
}
|
||||
ldapFields = append(ldapFields, field)
|
||||
}
|
||||
|
||||
return ldapFields, nil
|
||||
}
|
||||
|
||||
func appendLDAPCustomFieldAttributes(searchAttrs []string, fields []dto.CustomFieldDto) []string {
|
||||
seenAttrs := make(map[string]struct{}, len(searchAttrs)+len(fields))
|
||||
for _, attr := range searchAttrs {
|
||||
if attr == "" {
|
||||
continue
|
||||
}
|
||||
seenAttrs[attr] = struct{}{}
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
if _, ok := seenAttrs[field.Key]; ok {
|
||||
continue
|
||||
}
|
||||
searchAttrs = append(searchAttrs, field.Key)
|
||||
seenAttrs[field.Key] = struct{}{}
|
||||
}
|
||||
|
||||
return searchAttrs
|
||||
}
|
||||
|
||||
func customFieldValuesFromLDAPEntry(entry *ldap.Entry, fields []dto.CustomFieldDto) []dto.CustomFieldValueCreateDto {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
customFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
value := entry.GetAttributeValue(field.Key)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
customFieldValues = append(customFieldValues, dto.CustomFieldValueCreateDto{
|
||||
CustomFieldID: field.ID,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
return customFieldValues
|
||||
}
|
||||
|
||||
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()
|
||||
customFields, err := s.getLDAPCustomFields(UserGroupID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Query LDAP for all groups we want to manage
|
||||
searchAttrs := []string{
|
||||
@@ -225,6 +286,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||
dbConfig.LdapAttributeGroupMember.Value,
|
||||
}
|
||||
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
|
||||
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
dbConfig.LdapBase.Value,
|
||||
@@ -267,9 +329,10 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
}
|
||||
|
||||
syncGroup := dto.UserGroupCreateDto{
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapID,
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapID,
|
||||
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
|
||||
}
|
||||
dto.Normalize(&syncGroup)
|
||||
|
||||
@@ -291,6 +354,10 @@ 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()
|
||||
customFields, err := s.getLDAPCustomFields(UserID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Query LDAP for all users we want to manage
|
||||
searchAttrs := []string{
|
||||
@@ -304,6 +371,7 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
dbConfig.LdapAttributeUserProfilePicture.Value,
|
||||
dbConfig.LdapAttributeUserDisplayName.Value,
|
||||
}
|
||||
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
|
||||
|
||||
// Filters must start and finish with ()!
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
@@ -342,12 +410,13 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
ldapUserIDs[ldapID] = struct{}{}
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
EmailVerified: true,
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
EmailVerified: true,
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
|
||||
// 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,
|
||||
@@ -423,6 +492,11 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
}
|
||||
|
||||
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
|
||||
customFields, err := s.getLDAPCustomFields(UserGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
||||
if err != nil {
|
||||
@@ -448,28 +522,38 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
|
||||
}
|
||||
|
||||
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
|
||||
var groupID string
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
ldapGroupsByID[desiredGroup.ldapID] = newGroup
|
||||
groupID = newGroup.ID
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
groupID = databaseGroup.ID
|
||||
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||
if len(customFields) > 0 {
|
||||
_, err = s.groupService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserGroupID, groupID, desiredGroup.input.CustomFieldValues, customFields, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync custom fields for group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,6 +584,10 @@ 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()
|
||||
customFields, err := s.getLDAPCustomFields(UserID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||
@@ -551,6 +639,11 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.userService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserID, userID, desiredUser.input.CustomFieldValues, customFields, tx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to sync custom fields for user '%s': %w", desiredUser.input.Username, err)
|
||||
}
|
||||
|
||||
if desiredUser.picture != "" {
|
||||
savePictures = append(savePictures, savePicture{
|
||||
userID: userID,
|
||||
|
||||
@@ -141,6 +141,51 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllImportsCustomFieldsFromLDAP(t *testing.T) {
|
||||
appCfg := defaultTestLDAPAppConfig()
|
||||
appCfg.CustomFields = model.AppConfigVariable{Value: `[
|
||||
{"id":"5b6f0cb7-2865-4c2e-9795-4c81e3725f21","key":"quota","displayName":"Quota","type":"string","target":"user","required":false},
|
||||
{"id":"5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644","key":"mailboxTemplate","displayName":"Mailbox template","type":"string","target":"user","required":false},
|
||||
{"id":"9a98fcfb-1d0b-46a3-b028-3c43694b1771","key":"nextcloudQuota","displayName":"Group quota","type":"string","target":"group","required":false}
|
||||
]`}
|
||||
|
||||
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-alice"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alice"},
|
||||
"sn": {"Jones"},
|
||||
"displayName": {""},
|
||||
"quota": {"10 GB"},
|
||||
"mailboxTemplate": {"standard"},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-team"},
|
||||
"cn": {"team"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
"nextcloudQuota": {"100 GB"},
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var alice model.User
|
||||
require.NoError(t, db.Preload("CustomFieldValues").First(&alice, "ldap_id = ?", "u-alice").Error)
|
||||
userValues := customFieldValuesByID(alice.CustomFieldValues)
|
||||
assert.Equal(t, "10 GB", userValues["5b6f0cb7-2865-4c2e-9795-4c81e3725f21"])
|
||||
assert.Equal(t, "standard", userValues["5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644"])
|
||||
|
||||
var group model.UserGroup
|
||||
require.NoError(t, db.Preload("CustomFieldValues").First(&group, "ldap_id = ?", "g-team").Error)
|
||||
groupValues := customFieldValuesByID(group.CustomFieldValues)
|
||||
assert.Equal(t, "100 GB", groupValues["9a98fcfb-1d0b-46a3-b028-3c43694b1771"])
|
||||
}
|
||||
|
||||
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
|
||||
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
||||
appCfg := defaultTestLDAPAppConfig()
|
||||
@@ -318,14 +363,15 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
|
||||
|
||||
appConfig := NewTestAppConfigService(appConfigModel)
|
||||
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
customFieldValueService := NewCustomFieldValueService(db, appConfig)
|
||||
groupService := NewUserGroupService(db, appConfig, customFieldValueService, nil)
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
customFieldValueService,
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
@@ -405,6 +451,15 @@ func usernames(users []model.User) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func customFieldValuesByID(values []model.CustomFieldValue) map[string]string {
|
||||
result := make(map[string]string, len(values))
|
||||
for _, value := range values {
|
||||
result[value.CustomFieldID] = value.Value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func TestGetDNProperty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -51,13 +50,13 @@ const (
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
customClaimService *CustomClaimService
|
||||
webAuthnService *WebAuthnService
|
||||
scimService *ScimService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
customFieldValueService *CustomFieldValueService
|
||||
webAuthnService *WebAuthnService
|
||||
scimService *ScimService
|
||||
|
||||
httpClient *http.Client
|
||||
jwkCache *jwk.Cache
|
||||
@@ -70,22 +69,22 @@ func NewOidcService(
|
||||
jwtService *JwtService,
|
||||
appConfigService *AppConfigService,
|
||||
auditLogService *AuditLogService,
|
||||
customClaimService *CustomClaimService,
|
||||
customFieldValueService *CustomFieldValueService,
|
||||
webAuthnService *WebAuthnService,
|
||||
scimService *ScimService,
|
||||
httpClient *http.Client,
|
||||
fileStorage storage.FileStorage,
|
||||
) (s *OidcService, err error) {
|
||||
s = &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
customClaimService: customClaimService,
|
||||
webAuthnService: webAuthnService,
|
||||
scimService: scimService,
|
||||
httpClient: httpClient,
|
||||
fileStorage: fileStorage,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
customFieldValueService: customFieldValueService,
|
||||
webAuthnService: webAuthnService,
|
||||
scimService: scimService,
|
||||
httpClient: httpClient,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
|
||||
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
|
||||
@@ -2043,23 +2042,51 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "profile") {
|
||||
// Add custom claims
|
||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
|
||||
// We need to fetch the user and group fields first because we need the key of the custom key later
|
||||
userFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userFieldsByID := make(map[string]dto.CustomFieldDto, len(userFields))
|
||||
for _, customField := range userFields {
|
||||
userFieldsByID[customField.ID] = customField
|
||||
}
|
||||
|
||||
groupFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupFieldsByID := make(map[string]dto.CustomFieldDto, len(groupFields))
|
||||
for _, customField := range groupFields {
|
||||
groupFieldsByID[customField.ID] = customField
|
||||
}
|
||||
|
||||
// Fetch the actual values of the custom fields
|
||||
customFieldValues, err := s.customFieldValueService.GetCustomFieldValuesForUserWithUserGroups(ctx, user.ID, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customClaim := range customClaims {
|
||||
// The value of the custom claim can be a JSON object or a string
|
||||
var jsonValue any
|
||||
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
|
||||
if err == nil {
|
||||
// It's JSON, so we store it as an object
|
||||
claims[customClaim.Key] = jsonValue
|
||||
} else {
|
||||
// Marshaling failed, so we store it as a string
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
for _, customFieldValue := range customFieldValues {
|
||||
var customField *dto.CustomFieldDto
|
||||
var claimKey string
|
||||
if customFieldValue.UserID != nil {
|
||||
if field, ok := userFieldsByID[customFieldValue.CustomFieldID]; ok {
|
||||
customField = &field
|
||||
claimKey = field.Key
|
||||
}
|
||||
} else if customFieldValue.UserGroupID != nil {
|
||||
if field, ok := groupFieldsByID[customFieldValue.CustomFieldID]; ok {
|
||||
customField = &field
|
||||
claimKey = field.Key
|
||||
}
|
||||
}
|
||||
|
||||
value, err := customFieldValueTokenValue(customFieldValue, customField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims[claimKey] = value
|
||||
}
|
||||
|
||||
// Add profile claims
|
||||
|
||||
@@ -15,19 +15,24 @@ import (
|
||||
)
|
||||
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
customFieldValueService *CustomFieldValueService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, customFieldValueService: customFieldValueService, scimService: scimService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
query := s.db.
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Preload("CustomClaims").
|
||||
Model(&model.UserGroup{})
|
||||
|
||||
if name != "" {
|
||||
@@ -43,6 +48,17 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
for i := range groups {
|
||||
groups[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, groups[i].ID, tx)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return groups, response, err
|
||||
}
|
||||
|
||||
@@ -54,11 +70,19 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
Preload("CustomClaims").
|
||||
Preload("Users").
|
||||
Preload("AllowedOidcClients").
|
||||
First(&group).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
group.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, group.ID, tx)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
return group, err
|
||||
}
|
||||
|
||||
@@ -104,7 +128,22 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||
return s.createInternal(ctx, input, s.db)
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
group, err = s.createInternal(ctx, input, tx)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) {
|
||||
@@ -129,6 +168,12 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if input.LdapID == "" {
|
||||
if group.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
@@ -181,6 +226,12 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if input.CustomFieldValues != nil {
|
||||
if _, err := s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
@@ -27,37 +27,41 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
appImagesService *AppImagesService
|
||||
scimService *ScimService
|
||||
fileStorage storage.FileStorage
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customFieldValueService *CustomFieldValueService
|
||||
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 *AppConfigService, customFieldValueService *CustomFieldValueService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
appImagesService: appImagesService,
|
||||
scimService: scimService,
|
||||
fileStorage: fileStorage,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customFieldValueService: customFieldValueService,
|
||||
appImagesService: appImagesService,
|
||||
scimService: scimService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var users []model.User
|
||||
query := s.db.WithContext(ctx).
|
||||
query := tx.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Preload("UserGroups").
|
||||
Preload("CustomClaims")
|
||||
Preload("UserGroups")
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
@@ -67,6 +71,16 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequ
|
||||
}
|
||||
|
||||
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
users[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, users[i].ID, tx)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return users, pagination, err
|
||||
}
|
||||
@@ -80,10 +94,17 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("UserGroups").
|
||||
Preload("CustomClaims").
|
||||
Where("id = ?", userID).
|
||||
First(&user).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
user.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, user.ID, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
@@ -301,15 +322,17 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
// Apply default groups and custom fields for new non-LDAP users.
|
||||
if !isLdapSync {
|
||||
if len(input.UserGroupIds) == 0 {
|
||||
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
|
||||
}
|
||||
if !isLdapSync {
|
||||
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, input.CustomFieldValues, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
@@ -353,27 +376,6 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
var claims []dto.CustomClaimCreateDto
|
||||
v := config.SignupDefaultCustomClaims.Value
|
||||
if v != "" && v != "[]" {
|
||||
err := json.Unmarshal([]byte(v), &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
|
||||
}
|
||||
if len(claims) > 0 {
|
||||
_, err = s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply default custom claims: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
@@ -463,6 +465,15 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
return user, err
|
||||
}
|
||||
|
||||
if updateOwnUser {
|
||||
user.CustomFieldValues, err = s.customFieldValueService.updateSelfEditableCustomFieldValuesForUser(ctx, user.ID, updatedUser.CustomFieldValues, tx)
|
||||
} else {
|
||||
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, updatedUser.CustomFieldValues, tx)
|
||||
}
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
@@ -72,14 +72,20 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto
|
||||
}
|
||||
}
|
||||
|
||||
customFieldValues, err := s.filterSignupCustomFieldValues(signupData.CustomFieldValues)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||
UserGroupIds: userGroupIDs,
|
||||
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||
UserGroupIds: userGroupIDs,
|
||||
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
|
||||
CustomFieldValues: customFieldValues,
|
||||
}
|
||||
|
||||
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -132,13 +138,19 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
|
||||
return model.User{}, "", &common.SetupNotAvailableError{}
|
||||
}
|
||||
|
||||
customFieldValues, err := s.filterSignupCustomFieldValues(signUpData.CustomFieldValues)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
CustomFieldValues: customFieldValues,
|
||||
}
|
||||
|
||||
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -159,6 +171,34 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
|
||||
return user, token, nil
|
||||
}
|
||||
|
||||
func (s *UserSignUpService) filterSignupCustomFieldValues(customFieldValues []dto.CustomFieldValueCreateDto) ([]dto.CustomFieldValueCreateDto, error) {
|
||||
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowedFieldIDs := make(map[string]struct{}, len(fields))
|
||||
allowedFieldKeys := make(map[string]struct{}, len(fields))
|
||||
for _, field := range fields {
|
||||
if !customFieldAppliesTo(field, UserID) || (!field.Required && !field.UserEditable) {
|
||||
continue
|
||||
}
|
||||
allowedFieldIDs[field.ID] = struct{}{}
|
||||
allowedFieldKeys[field.Key] = struct{}{}
|
||||
}
|
||||
|
||||
filteredCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(customFieldValues))
|
||||
for _, customFieldValue := range customFieldValues {
|
||||
_, idAllowed := allowedFieldIDs[customFieldValue.CustomFieldID]
|
||||
_, keyAllowed := allowedFieldKeys[customFieldValue.Key]
|
||||
if idAllowed || keyAllowed {
|
||||
filteredCustomFieldValues = append(filteredCustomFieldValues, customFieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredCustomFieldValues, nil
|
||||
}
|
||||
|
||||
func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
|
||||
return s.isInitialAdminSetupCompleted(ctx, s.db)
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
CREATE TEMP TABLE custom_field_migration_map AS
|
||||
SELECT
|
||||
field.value->>'id' AS custom_field_id,
|
||||
field.value->>'key' AS key
|
||||
FROM app_config_variables
|
||||
CROSS JOIN LATERAL jsonb_array_elements(app_config_variables.value::jsonb) AS field(value)
|
||||
WHERE app_config_variables.key = 'customFields';
|
||||
|
||||
ALTER TABLE custom_field_values RENAME CONSTRAINT custom_field_values_unique TO custom_field_values_custom_field_id_unique;
|
||||
|
||||
ALTER TABLE custom_field_values ADD COLUMN key VARCHAR(255);
|
||||
|
||||
UPDATE custom_field_values
|
||||
SET key = custom_field_migration_map.key
|
||||
FROM custom_field_migration_map
|
||||
WHERE custom_field_migration_map.custom_field_id = custom_field_values.custom_field_id;
|
||||
|
||||
UPDATE custom_field_values
|
||||
SET key = custom_field_id
|
||||
WHERE key IS NULL;
|
||||
|
||||
ALTER TABLE custom_field_values ALTER COLUMN key SET NOT NULL;
|
||||
ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_custom_field_id_unique;
|
||||
ALTER TABLE custom_field_values DROP COLUMN custom_field_id;
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id);
|
||||
|
||||
ALTER TABLE custom_field_values RENAME TO custom_claims;
|
||||
|
||||
DROP TABLE custom_field_migration_map;
|
||||
|
||||
DELETE FROM app_config_variables WHERE key = 'customFields';
|
||||
@@ -0,0 +1,61 @@
|
||||
ALTER TABLE custom_claims RENAME TO custom_field_values;
|
||||
ALTER TABLE custom_field_values RENAME CONSTRAINT custom_claims_unique TO custom_field_values_key_unique;
|
||||
|
||||
ALTER TABLE custom_field_values ADD COLUMN custom_field_id VARCHAR(255);
|
||||
|
||||
CREATE TEMP TABLE custom_field_migration_map AS
|
||||
SELECT
|
||||
key,
|
||||
SUBSTRING(md5(key) FROM 1 FOR 8) || '-' ||
|
||||
SUBSTRING(md5(key) FROM 9 FOR 4) || '-' ||
|
||||
'4' || SUBSTRING(md5(key) FROM 14 FOR 3) || '-' ||
|
||||
'8' || SUBSTRING(md5(key) FROM 18 FOR 3) || '-' ||
|
||||
SUBSTRING(md5(key) FROM 21 FOR 12) AS custom_field_id,
|
||||
BOOL_OR(user_id IS NOT NULL) AS has_user_values,
|
||||
BOOL_OR(user_group_id IS NOT NULL) AS has_group_values
|
||||
FROM custom_field_values
|
||||
GROUP BY key;
|
||||
|
||||
INSERT INTO app_config_variables (key, value)
|
||||
SELECT
|
||||
'customFields',
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', custom_field_id,
|
||||
'key', key,
|
||||
'displayName', key,
|
||||
'type', 'string',
|
||||
'target',
|
||||
CASE
|
||||
WHEN has_user_values AND has_group_values THEN 'both'
|
||||
WHEN has_user_values THEN 'user'
|
||||
ELSE 'group'
|
||||
END,
|
||||
'required', false,
|
||||
'userEditable', false,
|
||||
'defaultValue', '',
|
||||
'validationRegex', '',
|
||||
'validationErrorMessage', ''
|
||||
)
|
||||
ORDER BY key
|
||||
)::TEXT,
|
||||
'[]'
|
||||
)
|
||||
FROM custom_field_migration_map
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
WHERE app_config_variables.value = '' OR app_config_variables.value = '[]';
|
||||
|
||||
UPDATE custom_field_values
|
||||
SET custom_field_id = custom_field_migration_map.custom_field_id
|
||||
FROM custom_field_migration_map
|
||||
WHERE custom_field_migration_map.key = custom_field_values.key;
|
||||
|
||||
ALTER TABLE custom_field_values ALTER COLUMN custom_field_id SET NOT NULL;
|
||||
ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_key_unique;
|
||||
ALTER TABLE custom_field_values DROP COLUMN key;
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id);
|
||||
|
||||
DROP TABLE custom_field_migration_map;
|
||||
|
||||
DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields');
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TEMP TABLE custom_field_migration_map AS
|
||||
SELECT
|
||||
json_extract(json_each.value, '$.id') AS custom_field_id,
|
||||
json_extract(json_each.value, '$.key') AS key
|
||||
FROM app_config_variables, json_each(app_config_variables.value)
|
||||
WHERE app_config_variables.key = 'customFields';
|
||||
|
||||
ALTER TABLE custom_field_values RENAME TO custom_field_values_old;
|
||||
|
||||
CREATE TABLE custom_claims
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
|
||||
user_id TEXT,
|
||||
user_group_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
|
||||
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||
);
|
||||
|
||||
INSERT INTO custom_claims (id, created_at, key, value, user_id, user_group_id)
|
||||
SELECT
|
||||
old.id,
|
||||
old.created_at,
|
||||
COALESCE(map.key, old.custom_field_id),
|
||||
old.value,
|
||||
old.user_id,
|
||||
old.user_group_id
|
||||
FROM custom_field_values_old old
|
||||
LEFT JOIN custom_field_migration_map map ON map.custom_field_id = old.custom_field_id;
|
||||
|
||||
DROP TABLE custom_field_values_old;
|
||||
DROP TABLE custom_field_migration_map;
|
||||
|
||||
DELETE FROM app_config_variables WHERE key = 'customFields';
|
||||
@@ -0,0 +1,73 @@
|
||||
ALTER TABLE custom_claims RENAME TO custom_field_values_old;
|
||||
|
||||
CREATE TEMP TABLE custom_field_migration_map AS
|
||||
SELECT
|
||||
key,
|
||||
lower(hex(randomblob(4))) || '-' ||
|
||||
lower(hex(randomblob(2))) || '-' ||
|
||||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||
substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||
lower(hex(randomblob(6))) AS custom_field_id,
|
||||
MAX(user_id IS NOT NULL) AS has_user_values,
|
||||
MAX(user_group_id IS NOT NULL) AS has_group_values
|
||||
FROM custom_field_values_old
|
||||
GROUP BY key;
|
||||
|
||||
INSERT INTO app_config_variables (key, value)
|
||||
VALUES (
|
||||
'customFields',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_group_array(
|
||||
json_object(
|
||||
'id', custom_field_id,
|
||||
'key', key,
|
||||
'displayName', key,
|
||||
'type', 'string',
|
||||
'target',
|
||||
CASE
|
||||
WHEN has_user_values = 1 AND has_group_values = 1 THEN 'both'
|
||||
WHEN has_user_values = 1 THEN 'user'
|
||||
ELSE 'group'
|
||||
END,
|
||||
'required', json('false'),
|
||||
'userEditable', json('false'),
|
||||
'defaultValue', '',
|
||||
'validationRegex', '',
|
||||
'validationErrorMessage', ''
|
||||
)
|
||||
)
|
||||
FROM custom_field_migration_map
|
||||
ORDER BY key
|
||||
),
|
||||
'[]'
|
||||
)
|
||||
)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
WHERE app_config_variables.value = '' OR app_config_variables.value = '[]';
|
||||
|
||||
CREATE TABLE custom_field_values
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
custom_field_id TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
|
||||
user_id TEXT,
|
||||
user_group_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id),
|
||||
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||
);
|
||||
|
||||
INSERT INTO custom_field_values (id, created_at, custom_field_id, value, user_id, user_group_id)
|
||||
SELECT old.id, old.created_at, map.custom_field_id, old.value, old.user_id, old.user_group_id
|
||||
FROM custom_field_values_old old
|
||||
JOIN custom_field_migration_map map ON map.key = old.key;
|
||||
|
||||
DROP TABLE custom_field_values_old;
|
||||
DROP TABLE custom_field_migration_map;
|
||||
|
||||
DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields');
|
||||
Reference in New Issue
Block a user