Protect LDAP-managed identity fields

This commit is contained in:
Firehawk 2026-07-12 02:26:53 +09:30
parent 3c337767f1
commit 0fbc6946a7
6 changed files with 74 additions and 16 deletions

View File

@ -244,6 +244,7 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas
u.IsAdmin = memberAny(du.Groups, src.AdminGroupDNs)
u.AuthSource = "ldap"
u.AuthSourceID = first(src.ID, src.Name)
u.ExternalSync = true
if err := repo.Put(u); err != nil {
return nil, err
}

View File

@ -27,6 +27,9 @@ type User struct {
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
// ExternalSync allows external auth providers to update read-only identity fields for managed users.
ExternalSync bool `structs:"-" json:"-"`
}
func (u User) HasLibraryAccess(libraryID int) bool {

View File

@ -182,8 +182,24 @@ func (r *userRepository) preserveExternalAuthSource(u *model.User) error {
if existing.AuthSource == "" {
return nil
}
if u.ExternalSync {
return nil
}
validation := &rest.ValidationError{Errors: map[string]string{}}
if u.NewPassword != "" {
return &rest.ValidationError{Errors: map[string]string{"password": "resources.user.validation.externalPasswordReadOnly"}}
validation.Errors["password"] = "resources.user.validation.externalFieldReadOnly"
}
if !strings.EqualFold(u.UserName, existing.UserName) {
validation.Errors["userName"] = "resources.user.validation.externalFieldReadOnly"
}
if u.Name != existing.Name {
validation.Errors["name"] = "resources.user.validation.externalFieldReadOnly"
}
if u.Email != existing.Email {
validation.Errors["email"] = "resources.user.validation.externalFieldReadOnly"
}
if len(validation.Errors) > 0 {
return validation
}
if u.AuthSource == "" {
u.AuthSource = existing.AuthSource

View File

@ -70,26 +70,45 @@ var _ = Describe("UserRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(actual.Password).To(Equal("newpass"))
})
It("does not update password for LDAP-sourced users", func() {
It("does not update managed fields for LDAP-sourced users", func() {
ldapUser := model.User{
ID: "ldap-user",
UserName: "ldap_user",
Name: "LDAP User",
Email: "ldap@example.com",
NewPassword: "generated",
AuthSource: "ldap",
AuthSourceID: "ldap01",
}
Expect(repo.Put(&ldapUser)).To(Succeed())
ldapUser.UserName = "renamed"
ldapUser.Name = "Renamed User"
ldapUser.Email = "renamed@example.com"
ldapUser.NewPassword = "newpass"
err := repo.Put(&ldapUser)
var verr *rest.ValidationError
Expect(errors.As(err, &verr)).To(BeTrue())
Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalPasswordReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("userName", "resources.user.validation.externalFieldReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("name", "resources.user.validation.externalFieldReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("email", "resources.user.validation.externalFieldReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalFieldReadOnly"))
actual, err := repo.FindByUsernameWithPassword("ldap_user")
Expect(err).ToNot(HaveOccurred())
Expect(actual.UserName).To(Equal("ldap_user"))
Expect(actual.Name).To(Equal("LDAP User"))
Expect(actual.Email).To(Equal("ldap@example.com"))
Expect(actual.Password).To(Equal("generated"))
actual.Name = "Synced User"
actual.Email = "synced@example.com"
actual.ExternalSync = true
Expect(repo.Put(actual)).To(Succeed())
actual, err = repo.FindByUsername("ldap_user")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Synced User"))
Expect(actual.Email).To(Equal("synced@example.com"))
})
})

View File

@ -168,14 +168,14 @@
},
"validation": {
"librariesRequired": "At least one library must be selected for non-admin users",
"externalPasswordReadOnly": "Password cannot be changed for users managed by an external authentication source."
"externalFieldReadOnly": "This field is managed by an external authentication source."
},
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
"clickHereForToken": "Click here to get your token",
"selectAllLibraries": "Select all libraries",
"adminAutoLibraries": "Admin users automatically have access to all libraries",
"externalPasswordReadOnly": "Password changes are disabled because this user is managed by an external authentication source."
"externalPasswordReadOnly": "Username, display name, email, and password changes are disabled because this user is managed by an external authentication source."
}
},
"player": {

View File

@ -121,18 +121,37 @@ const UserEdit = (props) => {
validate={validateForm}
>
{permissions === 'admin' && (
<TextInput
spellCheck={false}
source="userName"
validate={[required()]}
/>
<FormDataConsumer>
{({ formData }) => (
<TextInput
spellCheck={false}
source="userName"
validate={[required()]}
disabled={!!formData.authSource}
/>
)}
</FormDataConsumer>
)}
<TextInput
source="name"
validate={[required()]}
{...getNameHelperText()}
/>
<TextInput spellCheck={false} source="email" validate={[email()]} />
<FormDataConsumer>
{({ formData }) => (
<TextInput
source="name"
validate={[required()]}
disabled={!!formData.authSource}
{...getNameHelperText()}
/>
)}
</FormDataConsumer>
<FormDataConsumer>
{({ formData }) => (
<TextInput
spellCheck={false}
source="email"
validate={[email()]}
disabled={!!formData.authSource}
/>
)}
</FormDataConsumer>
<FormDataConsumer>
{({ formData }) =>
formData.authSource ? (