Skip to content

Conversation

@slobodanadamovic
Copy link
Contributor

@slobodanadamovic slobodanadamovic commented Oct 8, 2025

Fixing the wrong order of listener calls.

Note: This fix is only relevant when project authorization is used and cross-project enabled.
Labeling as >refactoring since the feature is under development and not affecting production code.

Kudos to @ywangd for tracking the issue down.

@slobodanadamovic slobodanadamovic self-assigned this Oct 8, 2025
@slobodanadamovic slobodanadamovic added :Security/Authorization Roles, Privileges, DLS/FLS, RBAC/ABAC Team:Security Meta label for security team labels Oct 8, 2025
@elasticsearchmachine elasticsearchmachine added the serverless-linked Added by automation, don't add manually label Oct 8, 2025
ProjectResolver projectResolver,
AuthorizedProjectsResolver authorizedProjectsResolver
AuthorizedProjectsResolver authorizedProjectsResolver,
CrossProjectModeDecider crossProjectModeDecider
Copy link
Contributor Author

@slobodanadamovic slobodanadamovic Oct 8, 2025

Choose a reason for hiding this comment

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

Providing CrossProjectModeDecider via constructor to allow easier testing of project authorization code-paths.

* @param <Response> the type of the response
* @return a listener that executes the appropriate consumer when the response (or failure) is received.
*/
private static <Response> ActionListener<Response> actionListenerWithErrorHandling(
Copy link
Contributor Author

@slobodanadamovic slobodanadamovic Oct 8, 2025

Choose a reason for hiding this comment

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

We need this to maintain the bwc error handling behaviour (e.g. returning 403 ElasticsearchSecurityException with IllegalStateException as a root cause).

It's debatable if the behaviour is correct or not in all cases, but I'd like to avoid opening that can of worms at this point. I'd prefer to keep the same error and status behaviour and not have to deal with potential breaking changes now.

Copy link
Member

Choose a reason for hiding this comment

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

It is not immediately obvious to me where IllegalStateException is thrown? I guess it is likely from authzEngine.authorizeIndexAction? If that's the case, we can wrap it with ActionListener.run? That is, something like

ActionListener.run( listener, l -> authzEngine.authorizeIndexAction(requestInfo, authzInfo, resolvedIndicesAsyncSupplier, projectMetadata) .addListener( wrapPreservingContext( new AuthorizationResultListener<>( result -> handleIndexActionAuthorizationResult( result, requestInfo, requestId, authzInfo, authzEngine, resolvedIndicesAsyncSupplier, projectMetadata, l ), l::onFailure, requestInfo, requestId, authzInfo ), threadContext ) ) );

and maybe with a comment explaining why wrapping is needed?

Copy link
Contributor Author

@slobodanadamovic slobodanadamovic Oct 9, 2025

Choose a reason for hiding this comment

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

Yes. The behaviour is that all unexpected exceptions thrown by authzEngine.authorizeIndexAction should not go through onAuthorizedResourceLoadFailure. They should be handled by original listener and result in 500 responses. On the other hand, the unexpected exception thrown by IndicesAndAliasesResolver do go through onAuthorizedResourceLoadFailure, and end up audited and wrapped in security exception. This is something I'd really like to fix but it's not straightforward since the exceptions thrown by resolver are not consistent. Also, I'd really like us to threat all unexpected exceptions consistently when security is enabled and only return a generic 500 message with unique identifier back to callers with logging an actual error (and cause) that includes unique identifier so it can be easily traced down.

Copy link
Contributor Author

@slobodanadamovic slobodanadamovic Oct 9, 2025

Choose a reason for hiding this comment

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

That is, something like

ActionListener.run( listener, l -> authzEngine.authorizeIndexAction(requestInfo, authzInfo, resolvedIndicesAsyncSupplier, projectMetadata) .addListener( wrapPreservingContext( new AuthorizationResultListener<>( result -> handleIndexActionAuthorizationResult( result, requestInfo, requestId, authzInfo, authzEngine, resolvedIndicesAsyncSupplier, projectMetadata, l ), l::onFailure, requestInfo, requestId, authzInfo ), threadContext ) ) );

and maybe with a comment explaining why wrapping is needed?

TIL Nice! I was looking for a suitable wrapper method but did not find it.

@slobodanadamovic slobodanadamovic marked this pull request as ready for review October 8, 2025 16:21
@elasticsearchmachine
Copy link
Collaborator

Pinging @elastic/es-security (Team:Security)

Copy link
Member

@ywangd ywangd left a comment

Choose a reason for hiding this comment

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

LGTM

Comment on lines 505 to 510
final var targetProjectListener = new SubscribableListener<TargetProjects>();
if (indicesAndAliasesResolver.resolvesCrossProject(request)) {
authorizedProjectsResolver.resolveAuthorizedProjects(targetProjectListener);
} else {
targetProjectListener.onResponse(TargetProjects.NOT_CROSS_PROJECT);
}
Copy link
Member

Choose a reason for hiding this comment

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

Nit: a little more efficient

Suggested change
final var targetProjectListener = new SubscribableListener<TargetProjects>();
if (indicesAndAliasesResolver.resolvesCrossProject(request)) {
authorizedProjectsResolver.resolveAuthorizedProjects(targetProjectListener);
} else {
targetProjectListener.onResponse(TargetProjects.NOT_CROSS_PROJECT);
}
final SubscribableListener<TargetProjects> targetProjectListener;
if (indicesAndAliasesResolver.resolvesCrossProject(request)) {
targetProjectListener = new SubscribableListener<>();
authorizedProjectsResolver.resolveAuthorizedProjects(targetProjectListener);
} else {
targetProjectListener = SubscribableListener.newSucceeded(TargetProjects.NOT_CROSS_PROJECT);
}
* @param <Response> the type of the response
* @return a listener that executes the appropriate consumer when the response (or failure) is received.
*/
private static <Response> ActionListener<Response> actionListenerWithErrorHandling(
Copy link
Member

Choose a reason for hiding this comment

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

It is not immediately obvious to me where IllegalStateException is thrown? I guess it is likely from authzEngine.authorizeIndexAction? If that's the case, we can wrap it with ActionListener.run? That is, something like

ActionListener.run( listener, l -> authzEngine.authorizeIndexAction(requestInfo, authzInfo, resolvedIndicesAsyncSupplier, projectMetadata) .addListener( wrapPreservingContext( new AuthorizationResultListener<>( result -> handleIndexActionAuthorizationResult( result, requestInfo, requestId, authzInfo, authzEngine, resolvedIndicesAsyncSupplier, projectMetadata, l ), l::onFailure, requestInfo, requestId, authzInfo ), threadContext ) ) );

and maybe with a comment explaining why wrapping is needed?

@slobodanadamovic slobodanadamovic added the auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) label Oct 9, 2025
@elasticsearchmachine elasticsearchmachine merged commit e36bb81 into elastic:main Oct 9, 2025
40 checks passed
@slobodanadamovic slobodanadamovic deleted the cps-project-authorization branch October 9, 2025 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) >refactoring :Security/Authorization Roles, Privileges, DLS/FLS, RBAC/ABAC serverless-linked Added by automation, don't add manually Team:Security Meta label for security team v9.3.0

3 participants