Skip to content

Fix error message when changing the password for a user in the file realm #127621

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fe01fd3
This PR addresses #113535 - a confusing error message when the user a…
ankit--sethi May 1, 2025
e4681be
Update docs/changelog/127621.yaml
ankit--sethi May 1, 2025
7b5d0d5
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 1, 2025
9efe82e
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 2, 2025
d3f5c35
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 5, 2025
aab001f
re-do the ticket in Continuation-passing style. Previous unanswered q…
ankit--sethi May 7, 2025
885cfc7
Merge remote-tracking branch 'origin/feature/misleading-error-message…
ankit--sethi May 7, 2025
96158ff
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 7, 2025
8d39db9
link issue
ankit--sethi May 7, 2025
bf32356
accidental commit, reverting
ankit--sethi May 7, 2025
d0faee2
Update docs/changelog/127621.yaml
ankit--sethi May 7, 2025
54889da
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 8, 2025
3fc8aa9
try to check for membership in all non-native realms
ankit--sethi May 8, 2025
76c1afc
Merge remote-tracking branch 'origin/feature/misleading-error-message…
ankit--sethi May 8, 2025
448081d
[CI] Auto commit changes from spotless
elasticsearchmachine May 8, 2025
028b2b8
improve checks to fix failing tests
ankit--sethi May 20, 2025
e40fd67
improve
ankit--sethi May 20, 2025
d0f7971
Merge remote-tracking branch 'origin/feature/misleading-error-message…
ankit--sethi May 20, 2025
173032a
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 20, 2025
099707e
improve
ankit--sethi May 21, 2025
9f2d92c
Merge remote-tracking branch 'origin/main' into feature/misleading-er…
ankit--sethi May 21, 2025
4c19a6f
Merge branch 'main' into feature/misleading-error-message
ankit--sethi May 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/127621.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 127621
summary: Fix error message when changing the password for a user in the file realm
area: Security
type: bug
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,48 @@
import org.elasticsearch.action.ActionType;
import org.elasticsearch.action.support.ActionFilters;
import org.elasticsearch.action.support.HandledTransportAction;
import org.elasticsearch.common.ValidationException;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.EsExecutors;
import org.elasticsearch.injection.guice.Inject;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.action.user.ChangePasswordRequest;
import org.elasticsearch.xpack.core.security.authc.Realm;
import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings;
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
import org.elasticsearch.xpack.core.security.user.AnonymousUser;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.security.authc.Realms;
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.file.FileRealm;

import java.util.Locale;
import java.util.Optional;

import static org.elasticsearch.xpack.core.security.user.UsernamesField.ELASTIC_NAME;
import static org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore.USER_NOT_FOUND_MESSAGE;

public class TransportChangePasswordAction extends HandledTransportAction<ChangePasswordRequest, ActionResponse.Empty> {

public static final ActionType<ActionResponse.Empty> TYPE = new ActionType<>("cluster:admin/xpack/security/user/change_password");
private final Settings settings;
private final NativeUsersStore nativeUsersStore;
private final Realms realms;

@Inject
public TransportChangePasswordAction(
Settings settings,
TransportService transportService,
ActionFilters actionFilters,
NativeUsersStore nativeUsersStore
NativeUsersStore nativeUsersStore,
Realms realms
) {
super(TYPE.name(), transportService, actionFilters, ChangePasswordRequest::new, EsExecutors.DIRECT_EXECUTOR_SERVICE);
this.settings = settings;
this.nativeUsersStore = nativeUsersStore;
this.realms = realms;
}

@Override
Expand All @@ -62,6 +75,58 @@ protected void doExecute(Task task, ChangePasswordRequest request, ActionListene
);
return;
}
nativeUsersStore.changePassword(request, listener.safeMap(v -> ActionResponse.Empty.INSTANCE));

// check if user exists in the native realm
nativeUsersStore.getUser(username, new ActionListener<>() {
@Override
public void onResponse(User user) {
Optional<Realm> realm = realms.stream().filter(t -> FileRealmSettings.TYPE.equalsIgnoreCase(t.type())).findAny();
if (user == null && realm.isPresent()) {
// we should check if this request is mistakenly trying to reset a file realm user
FileRealm fileRealm = (FileRealm) realm.get();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only file realms? Couldn't we just do this for all realms?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair point, maybe we can? I'm gonna take a closer look to see if there are any gotchas to doing that.

fileRealm.lookupUser(username, new ActionListener<>() {
@Override
public void onResponse(User user) {
if (user != null) {
ValidationException validationException = new ValidationException();
validationException.addValidationError(
"user ["
+ username
+ "] is file-based and cannot be managed via this API."
+ (ELASTIC_NAME.equalsIgnoreCase(username)
? " To update the '" + ELASTIC_NAME + "' user in a cloud deployment, use the console."
: "")
);
onFailure(validationException);
} else {
onFailure(createUserNotFoundException());
}
}

@Override
public void onFailure(Exception e) {
listener.onFailure(e);
}
});
} else if (user == null) {
listener.onFailure(createUserNotFoundException());
} else {
// safe to proceed
nativeUsersStore.changePassword(request, listener.safeMap(v -> ActionResponse.Empty.INSTANCE));
}
}

@Override
public void onFailure(Exception e) {
listener.onFailure(e);
}
});

}

private static ValidationException createUserNotFoundException() {
ValidationException validationException = new ValidationException();
validationException.addValidationError(USER_NOT_FOUND_MESSAGE);
return validationException;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public class NativeUsersStore {

public static final String USER_DOC_TYPE = "user";
public static final String RESERVED_USER_TYPE = "reserved-user";
public static final String USER_NOT_FOUND_MESSAGE = "user must exist in order to change password";
private static final Logger logger = LogManager.getLogger(NativeUsersStore.class);

private final Settings settings;
Expand Down Expand Up @@ -316,7 +317,7 @@ public void onFailure(Exception e) {
} else {
logger.debug(() -> format("failed to change password for user [%s]", request.username()), e);
ValidationException validationException = new ValidationException();
validationException.addValidationError("user must exist in order to change password");
validationException.addValidationError(USER_NOT_FOUND_MESSAGE);
listener.onFailure(validationException);
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.action.support.ActionFilters;
import org.elasticsearch.common.ValidationException;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.test.ESTestCase;
Expand All @@ -25,11 +26,15 @@
import org.elasticsearch.xpack.core.security.user.ElasticUser;
import org.elasticsearch.xpack.core.security.user.KibanaUser;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.security.authc.Realms;
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.file.FileRealm;
import org.mockito.Mockito;

import java.util.Collections;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;

import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
import static org.elasticsearch.test.SecurityIntegTestCase.getFastStoredHashAlgoForTests;
Expand All @@ -39,6 +44,7 @@
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.sameInstance;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
Expand All @@ -55,7 +61,9 @@ public void testAnonymousUser() {
.put(XPackSettings.PASSWORD_HASHING_ALGORITHM.getKey(), hasher.name())
.build();
AnonymousUser anonymousUser = new AnonymousUser(settings);

NativeUsersStore usersStore = mock(NativeUsersStore.class);

TransportService transportService = new TransportService(
Settings.EMPTY,
mock(Transport.class),
Expand All @@ -69,7 +77,8 @@ public void testAnonymousUser() {
settings,
transportService,
mock(ActionFilters.class),
usersStore
usersStore,
mockRealms()
);
// Request will fail before the request hashing algorithm is checked, but we use the same algorithm as in settings for consistency
ChangePasswordRequest request = new ChangePasswordRequest();
Expand All @@ -96,6 +105,64 @@ public void onFailure(Exception e) {
verifyNoMoreInteractions(usersStore);
}

public void testFileRealmUser() {
final Hasher hasher = getFastStoredHashAlgoForTests();
Settings settings = Settings.builder()
.put(AnonymousUser.ROLES_SETTING.getKey(), "superuser")
.put(XPackSettings.PASSWORD_HASHING_ALGORITHM.getKey(), hasher.name())
.build();
ElasticUser elasticUser = new ElasticUser(true);
NativeUsersStore usersStore = mock(NativeUsersStore.class);

TransportService transportService = new TransportService(
Settings.EMPTY,
mock(Transport.class),
mock(ThreadPool.class),
TransportService.NOOP_TRANSPORT_INTERCEPTOR,
x -> null,
null,
Collections.emptySet()
);
TransportChangePasswordAction action = new TransportChangePasswordAction(
settings,
transportService,
mock(ActionFilters.class),
usersStore,
mockRealms(mockFileRealm(elasticUser))
);
ChangePasswordRequest request = new ChangePasswordRequest();
request.username(elasticUser.principal());
request.passwordHash(hasher.hash(SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING));

doAnswer(invocation -> {
Object[] args = invocation.getArguments();
assert args.length == 2;
@SuppressWarnings("unchecked")
ActionListener<User> listener = (ActionListener<User>) args[1];
listener.onResponse(null);
return null;
}).when(usersStore).getUser(eq(request.username()), anyActionListener());

final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
final AtomicReference<ActionResponse.Empty> responseRef = new AtomicReference<>();
action.doExecute(mock(Task.class), request, new ActionListener<>() {
@Override
public void onResponse(ActionResponse.Empty changePasswordResponse) {
responseRef.set(changePasswordResponse);
}

@Override
public void onFailure(Exception e) {
throwableRef.set(e);
}
});

assertThat(responseRef.get(), is(nullValue()));
assertThat(throwableRef.get(), instanceOf(ValidationException.class));
assertThat(throwableRef.get().getMessage(), containsString("is file-based and cannot be managed via this API"));
verify(usersStore, times(0)).changePassword(any(ChangePasswordRequest.class), anyActionListener());
}

public void testValidUser() {
testValidUser(randomFrom(new ElasticUser(true), new KibanaUser(true), new User("joe")));
}
Expand Down Expand Up @@ -127,12 +194,21 @@ private void testValidUser(User user) {
null,
Collections.emptySet()
);
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
assert args.length == 2;
@SuppressWarnings("unchecked")
ActionListener<User> listener = (ActionListener<User>) args[1];
listener.onResponse(user);
return null;
}).when(usersStore).getUser(eq(request.username()), anyActionListener());
Settings passwordHashingSettings = Settings.builder().put(XPackSettings.PASSWORD_HASHING_ALGORITHM.getKey(), hasher.name()).build();
TransportChangePasswordAction action = new TransportChangePasswordAction(
passwordHashingSettings,
transportService,
mock(ActionFilters.class),
usersStore
usersStore,
mockRealms()
);
final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
final AtomicReference<ActionResponse.Empty> responseRef = new AtomicReference<>();
Expand Down Expand Up @@ -177,7 +253,8 @@ public void testWithPasswordThatsNotAHash() {
passwordHashingSettings,
transportService,
mock(ActionFilters.class),
usersStore
usersStore,
mockRealms()
);
action.doExecute(mock(Task.class), request, new ActionListener<>() {
@Override
Expand Down Expand Up @@ -215,6 +292,14 @@ public void testWithDifferentPasswordHashingAlgorithm() {
listener.onResponse(null);
return null;
}).when(usersStore).changePassword(eq(request), anyActionListener());
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
assert args.length == 2;
@SuppressWarnings("unchecked")
ActionListener<User> listener = (ActionListener<User>) args[1];
listener.onResponse(user);
return null;
}).when(usersStore).getUser(eq(request.username()), anyActionListener());
final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
final AtomicReference<ActionResponse.Empty> responseRef = new AtomicReference<>();
TransportService transportService = new TransportService(
Expand All @@ -235,7 +320,8 @@ public void testWithDifferentPasswordHashingAlgorithm() {
passwordHashingSettings,
transportService,
mock(ActionFilters.class),
usersStore
usersStore,
mockRealms()
);
action.doExecute(mock(Task.class), request, new ActionListener<>() {
@Override
Expand Down Expand Up @@ -271,6 +357,14 @@ public void testException() {
listener.onFailure(e);
return null;
}).when(usersStore).changePassword(eq(request), anyActionListener());
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
assert args.length == 2;
@SuppressWarnings("unchecked")
ActionListener<User> listener = (ActionListener<User>) args[1];
listener.onResponse(user);
return null;
}).when(usersStore).getUser(eq(request.username()), anyActionListener());
TransportService transportService = new TransportService(
Settings.EMPTY,
mock(Transport.class),
Expand All @@ -285,7 +379,8 @@ public void testException() {
passwordHashingSettings,
transportService,
mock(ActionFilters.class),
usersStore
usersStore,
mockRealms()
);
final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
final AtomicReference<ActionResponse.Empty> responseRef = new AtomicReference<>();
Expand All @@ -306,4 +401,30 @@ public void onFailure(Exception e) {
assertThat(throwableRef.get(), sameInstance(e));
verify(usersStore, times(1)).changePassword(eq(request), anyActionListener());
}

private static FileRealm mockFileRealm(User fileRealmUser) {

FileRealm fileRealm = Mockito.mock(FileRealm.class);
Mockito.when(fileRealm.type()).thenReturn("file");
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
assert args.length == 2;
@SuppressWarnings("unchecked")
ActionListener<User> listener = (ActionListener<User>) args[1];
listener.onResponse(fileRealmUser);
return null;
}).when(fileRealm).lookupUser(Mockito.any(String.class), anyActionListener());

return fileRealm;
}

private static Realms mockRealms() {
return mockRealms(mockFileRealm(null));
}

private static Realms mockRealms(FileRealm realm) {
Realms realms = mock(Realms.class);
Mockito.when(realms.stream()).thenReturn(realm == null ? Stream.of() : Stream.of(realm));
return realms;
}
}
Loading