Skip to content
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

Fix Security's expression resolver to not remove unavailable but authorized names #92625

Merged

Conversation

albertzaharovits
Copy link
Contributor

@albertzaharovits albertzaharovits commented Jan 3, 2023

For explicit (non-wildcard) names in expressions from requests with the ignoreUnavailable request option set to true, the Security filter removes names for "unavailable" resources. This behavior is theoretically correct, but in practice the cluster state might not be recovered at the point in time when the Security filter does the rewrite (this causes problems, see #90215); In any case, the logic to remove "unavailable" names is unnecessarily duplicated from Core's expression resolver.

This PR makes the expression resolver in the Security filter to NOT:

  1. remove missing but authorized names (indices, aliases, datastreams)
  2. remove authorized datastreams if context disallows datastreams
  3. remove backing indices of authorized datastreams if context disallows datastreams

It will only remove names of unauthorized resources, and let the unavailable names go through to be handled (to be ignored or throw "not found" exception) by the expression resolver in Core.

Fixes: #90215

@albertzaharovits albertzaharovits changed the title Fix authz unavailable idx Fix Security's expression resolver to not remove unavailable but authorized names Jan 5, 2023
.setIndicesOptions(IndicesOptions.lenientExpandOpen())::get,
GetAliasesAction.NAME,
"aliases_only"
);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The ignore_unavailable request option does not currently work for "unavailable" aliases, see:

private static List<String> replaceWildcardsWithAuthorizedAliases(String[] aliases, List<String> authorizedAliases) {
.

It worked in this particular test by coincidence.
In this particular test the test_1 index is authorized but non-existent, so the request was previously authorized after the request was rewritten to empty. But now this is not the case anymore.
test_1 is authorized, so it is not considered "unavailable" from Security's POV,

Copy link
Member

Choose a reason for hiding this comment

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

From end-user's perspective, strictly speaking, this is a slight behaviour change:

  • Status code from 404 to 403
  • Response content from containing found aliases (and missing aliases) to unauthorized error response

Just want to call it out explicitly. But I don't think it is a real concern because the existing response is not a 200 anyway and it feels like an edge case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's indeed a behavior change for an edge case.

Rereading this now, after a week, I realise I haven't done a good job explaining what's going on here.
Let me try again:

The get aliases action in this test uses the "lenient expand open" request option, which means that missing explicit indices are ignored (and that wildcards can expand to empty).
The test_1 index does not exist, and it is the only index in the request, so when retrieving the non_authorized alias for it, because of the request option, this previously returned a 200 empty result, because there were ultimately no indices to return the aliases for.
But, after the change, Security does not care if test_1 exists or not (Core does, and if Security lets the request through, given the request option, it will return the empty result). For Security, the test_1 is authorized, but the alias non_authorized is not, hence it will then return a 403 because of the alias. For indices, the request option ignore_unavailable silently discards unauthorized index names, but the option never discards unauthorized aliases (but maybe it should?).

Therefore, clients will notice behavior differences for this API, when all the indices are authorized but missing, the request option tells to ignore missing indices, and there are unauthorized aliases (missing or not). Before they got a 200 empty, after they get a 403.
They got

Copy link
Member

Choose a reason for hiding this comment

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

Before they got a 200 empty, after they get a 403.

Does it return 200 today? At REST layer, the response code is set to 404 if there is any explicitly requested alias not found.

if (missingAliases.isEmpty()) {
status = RestStatus.OK;
} else {
status = RestStatus.NOT_FOUND;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're correct. It does return 404, in the existing code, with Security enabled, when user is only authorized for the test_1 index that does not exist, but it is not authorized for the non_authorized alias.

Comment on lines 881 to 892
}, name -> {
final IndexAbstraction indexAbstraction = lookup.get(name);
if (indexAbstraction == null) {
return false;
}
if (includeDataStreams) {
// missing but authorized resources should be handled downstream in the action handler, not here in the Security filter
return predicate.testMissingResource(name);
} else {
// We check the parent data stream first if there is one. For testing requested indices, this is most likely
// more efficient than checking the index name first because we recommend grant privileges over data stream
// instead of backing indices.
if (indexAbstraction.getParentDataStream() != null && predicate.test(indexAbstraction.getParentDataStream())) {
return true;
} else {
return predicate.test(indexAbstraction);
}
} else {
return indexAbstraction.getType() != IndexAbstraction.Type.DATA_STREAM && predicate.test(indexAbstraction);
return (indexAbstraction.getParentDataStream() != null && predicate.test(indexAbstraction.getParentDataStream()))
|| predicate.test(indexAbstraction);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the core of the change proposed by this PR.

This predicate here is used to remove explicit names when rewriting the request if ignore_unavailable is true, see:

} else if (indicesOptions.ignoreUnavailable() == false || isAuthorized.test(indexAbstraction)) {

After this expression rewrite, the request goes into the authz stage, where every expression item is checked again:

private boolean isActionGranted(final String action, final Map<String, IndexResource> requestedResources) {

The intent is that the test here, ie:

final IndexAbstraction indexAbstraction = lookup.get(name);
if (indexAbstraction == null) {
    // missing but authorized resources should be handled downstream in the action handler, not here in the Security filter
    return predicate.testMissingResource(name);
} else {
    // We check the parent data stream first if there is one. For testing requested indices, this is most likely
    // more efficient than checking the index name first because we recommend grant privileges over data stream
    // instead of backing indices.
    return (indexAbstraction.getParentDataStream() != null && predicate.test(indexAbstraction.getParentDataStream()))
        || predicate.test(indexAbstraction);
}

be equivalent to the test in IndicesPermission#isActionGranted, such that only unauthorized names be removed when rewriting the expression with ignore_unavailable=true.

Copy link
Member

Choose a reason for hiding this comment

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

Can we unify the loigc of test and testMissingResource into a single method? It is better that callers do not have to worry about deciding on which method to use. It should be possible if the predicate takes an object that encapsulate both name and indexAbstraction, something similar to IndicesPermission#IndexResource.

I think ideally it would be great to reuse the existing IndexResource and unify the logic between this method and isActionGranted. It will likely help understand this part of code. But it does not seem to be an easy job. So we can tackle it later gradually. But I think we can avoid having both test and testMissingResource as suggest earlier. It also helps the IsResourceAuthorizedPredicate to be just a simple Predicate and avoid having to implement its own and method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's a subtle problem with making the IsResourceAuthorizedPredicate take an IndexResource parameter.
The problem is that, when the resource exists, it matters if the "is resource authorized" predicate is called in the context of wildcard expansion, or in the context of checking explicit names.

The difference is that the predicate in the context of checking explicit names first tests on the parent datastream (if applicable) and then it tests on the resource itself. This is not the case for the predicate in the wildcard expansion context.

So, in essence, the IsResourceAuthorizedPredicate would still have to implement two slightly different test methods.

Not exactly what you asked for, but I could be hidding this whole

final IndexAbstraction indexAbstraction = lookup.get(name);
if (indexAbstraction == null) {
    // missing but authorized resources should be handled downstream in the action handler, not here in the Security filter
    return predicate.testMissingResource(name);
} else {
    // We check the parent data stream first if there is one. For testing requested indices, this is most likely
    // more efficient than checking the index name first because we recommend grant privileges over data stream
    // instead of backing indices.
    return (indexAbstraction.getParentDataStream() != null && predicate.test(indexAbstraction.getParentDataStream()))
        || predicate.test(indexAbstraction);
}

block inside a method in the IsResourceAuthorizedPredicate class, something of the sorts of #testExplicitName. This should be equivalent (and someday replace) the isActionGranted method.
What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

My preference is to avoid having a second test method for IsResourceAuthorizedPredicate. It is surprising that a predicate has more than one test method and you need some effort in deciding which one to use.

Based on your comment here #92625 (comment)

Can we make IsResourceAuthorizedPredicate implement BiPredicate if you prefer not having a IndexResource like class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As we've discussed earlier today, I've reimplemented the IsResourceAuthorizedPredicate as a BiPredicate<String, IndexAbstraction>, see 878ba9e :

    public static class IsResourceAuthorizedPredicate implements BiPredicate<String, IndexAbstraction> {

        private final BiPredicate<String, IndexAbstraction> biPredicate;

        // public for tests
        public IsResourceAuthorizedPredicate(StringMatcher resourceNameMatcher, StringMatcher additionalNonDatastreamNameMatcher) {
            this((String name, @Nullable IndexAbstraction indexAbstraction) -> {
                assert indexAbstraction == null || name.equals(indexAbstraction.getName());
                return resourceNameMatcher.test(name)
                    || (isPartOfDatastream(indexAbstraction) == false && additionalNonDatastreamNameMatcher.test(name));
            });
        }

        private IsResourceAuthorizedPredicate(BiPredicate<String, IndexAbstraction> biPredicate) {
            this.biPredicate = biPredicate;
        }

        public final IsResourceAuthorizedPredicate and(IsResourceAuthorizedPredicate other) {
            return new IsResourceAuthorizedPredicate(this.biPredicate.and(other.biPredicate));
        }

        public final boolean test(IndexAbstraction indexAbstraction) {
            return test(indexAbstraction.getName(), indexAbstraction);
        }

        @Override
        public boolean test(String name, @Nullable IndexAbstraction indexAbstraction) {
            return biPredicate.test(name, indexAbstraction);
        }

        private static boolean isPartOfDatastream(IndexAbstraction indexAbstraction) {
            return indexAbstraction != null
                && (indexAbstraction.getType() == IndexAbstraction.Type.DATA_STREAM || indexAbstraction.getParentDataStream() != null);
        }
    }

For convenience, I also added a test(IndexAbstraction indexAbstraction) in addition to test(String name, @Nullable IndexAbstraction indexAbstraction), to avoid the test(index.getName(), index) pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

However, I'm hesitant to change the predicate further, in order to move the logic that tests the parent datastream of a backing index. I still can't see any serious issues, but I very much prefer that such a change be in a separate PR.

Copy link
Member

Choose a reason for hiding this comment

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

OK let's keep it separate. Thanks for consideration.

}

@Override
protected String getInexistentWildcardErrorMessage() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some EQL tests assert the audit log messages, and some will change because the transport request, which gets audited, is not rewritten anymore to erase authorized but unavailable indices.

@albertzaharovits albertzaharovits added :Security/Authorization Roles, Privileges, DLS/FLS, RBAC/ABAC >bug labels Jan 5, 2023
@albertzaharovits albertzaharovits marked this pull request as ready for review January 5, 2023 14:50
@elasticsearchmachine elasticsearchmachine added the Team:Security Meta label for security team label Jan 5, 2023
@elasticsearchmachine
Copy link
Collaborator

Hi @albertzaharovits, I've created a changelog YAML for you.

@elasticsearchmachine
Copy link
Collaborator

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

@albertzaharovits
Copy link
Contributor Author

@jakelandis and @ywangd Thank you for the review, I acknowledge it must've been laborious.
I answered all your points, but I haven't pushed any code changes.
@ywangd We probably need to sync on the IsResourceAuthorizedPredicate's interface, because I'm undecided on the approach, but we can also figure it out over GH too.

@albertzaharovits
Copy link
Contributor Author

@elasticmachine update branch

@albertzaharovits
Copy link
Contributor Author

@elasticmachine update branch

@albertzaharovits
Copy link
Contributor Author

@elasticmachine update branch

Copy link
Contributor

@jakelandis jakelandis left a comment

Choose a reason for hiding this comment

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

LGTM , but would appreciate a second set of eyes for final approval.

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

A few minor comments for your consideration.

Comment on lines 881 to 892
}, name -> {
final IndexAbstraction indexAbstraction = lookup.get(name);
if (indexAbstraction == null) {
return false;
}
if (includeDataStreams) {
// missing but authorized resources should be handled downstream in the action handler, not here in the Security filter
return predicate.testMissingResource(name);
} else {
// We check the parent data stream first if there is one. For testing requested indices, this is most likely
// more efficient than checking the index name first because we recommend grant privileges over data stream
// instead of backing indices.
if (indexAbstraction.getParentDataStream() != null && predicate.test(indexAbstraction.getParentDataStream())) {
return true;
} else {
return predicate.test(indexAbstraction);
}
} else {
return indexAbstraction.getType() != IndexAbstraction.Type.DATA_STREAM && predicate.test(indexAbstraction);
return (indexAbstraction.getParentDataStream() != null && predicate.test(indexAbstraction.getParentDataStream()))
|| predicate.test(indexAbstraction);
}
Copy link
Member

Choose a reason for hiding this comment

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

OK let's keep it separate. Thanks for consideration.

@albertzaharovits
Copy link
Contributor Author

@albertzaharovits
Copy link
Contributor Author

@elasticmachine run elasticsearch-ci/part-2

@albertzaharovits albertzaharovits merged commit 54a78c3 into elastic:main Jan 19, 2023
@albertzaharovits albertzaharovits deleted the fix-authz-unavailable-idx branch January 19, 2023 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
>bug :Security/Authorization Roles, Privileges, DLS/FLS, RBAC/ABAC Team:Security Meta label for security team v8.7.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Get Index API returns no indices for a brief amount of time until indices are recovered into cluster_state
5 participants