Skip to content

Commit b15f680

Browse files
committed
Squashed commit of the following:
commit 3e60a91 Author: jaymode <[email protected]> Date: Mon Feb 4 12:34:23 2019 -0700 add licensing for authorization engine commit 1c9a8e1 Author: jaymode <[email protected]> Date: Mon Feb 4 12:17:54 2019 -0700 fix inconsistency in parameter name/type commit 34aa55a Author: Jay Modi <[email protected]> Date: Mon Feb 4 11:45:01 2019 -0700 Authorization engines evaluate privileges for APIs (elastic#38219) This commit moves the evaluation of privileges from a few transport actions into the authorization engine. The APIs are used by other applications for making decisions and if a different authorization engine is used that is not role based, we should still allow these APIs to work. By moving this evaluation out of the transport action, the transport actions no longer have a dependency on roles. commit 54d7b4c Merge: e5615d2 715e581 Author: jaymode <[email protected]> Date: Mon Feb 4 08:06:14 2019 -0700 Merge branch 'master' into security_authz_engine commit e5615d2 Author: Jay Modi <[email protected]> Date: Mon Feb 4 07:53:37 2019 -0700 Move request interceptors to AuthorizationService (elastic#38137) This change moves the RequestInterceptor iteration from the action filter to the AuthorizationService. This is done to remove the need for the use of a role within the request interceptors and replace it with the AuthorizationEngine. The AuthorizationEngine interface was also enhanced with a new method that is used to determine if a users permission on one index is a subset of their permissions on a list of indices or aliases. Additionally, this change addresses some leftover cleanups. commit 0e1c191 Merge: 3280607 b7de8e1 Author: jaymode <[email protected]> Date: Thu Jan 31 08:56:45 2019 -0700 Merge branch 'master' into security_authz_engine commit 3280607 Author: Jay Modi <[email protected]> Date: Tue Jan 29 14:17:37 2019 -0700 Allow authorization engines as an extension (elastic#37785) Authorization engines can now be registered by implementing a plugin, which also has a service implementation of a security extension. Only one extension may register an authorization engine and this engine will be used for all users except reserved realm users and internal users. commit d628008 Author: jaymode <[email protected]> Date: Tue Jan 29 10:06:09 2019 -0700 fix RBACEngine after restricted indices changes commit 5074683 Merge: 74f2e99 3c9f703 Author: jaymode <[email protected]> Date: Tue Jan 29 08:09:39 2019 -0700 Merge branch 'master' into security_authz_engine commit 74f2e99 Merge: 7846ee8 899dfc3 Author: jaymode <[email protected]> Date: Fri Jan 25 15:02:07 2019 -0700 Merge branch 'master' into security_authz_engine commit 7846ee8 Merge: b9a2c81 a81931b Author: jaymode <[email protected]> Date: Thu Jan 24 07:52:08 2019 -0700 Merge branch 'master' into security_authz_engine commit b9a2c81 Author: jaymode <[email protected]> Date: Tue Jan 22 09:48:11 2019 -0700 Fix resolving restricted indices after merging commit d98a77a Merge: 83cde40 5c1a1f7 Author: jaymode <[email protected]> Date: Tue Jan 22 09:09:23 2019 -0700 Merge branch 'master' into security_authz_engine commit 83cde40 Author: Jay Modi <[email protected]> Date: Tue Jan 22 08:03:19 2019 -0700 Add javadoc to the AuthorizationEngine interface (elastic#37620) This commit adds javadocs to the AuthorizationEngine interface aimed at developers of an authorization engine. Additionally, some classes were also moved to the core project so that they are ready to be exposed once we allow authorization engines to be plugged in. commit 9a240c6 Author: Jay Modi <[email protected]> Date: Thu Jan 17 19:33:35 2019 -0700 Encapsulate request, auth, and action name (elastic#37495) This change introduces a new class called RequestInfo that encapsulates the common objects that are passed to the authorization engine methods. By doing so, we give ourselves a way of adding additional data without breaking the interface. Additionally, this also reduces the need to ensure we pass these three parameters in the same order everywhere for consistency. commit 6278eab Merge: c555a44 4351a5e Author: jaymode <[email protected]> Date: Thu Jan 17 07:51:32 2019 -0700 Merge branch 'master' into security_authz_engine commit c555a44 Merge: 1362ab6 ecf0de3 Author: jaymode <[email protected]> Date: Wed Jan 16 10:24:33 2019 -0700 Merge branch 'master' into security_authz_engine commit 1362ab6 Author: Jay Modi <[email protected]> Date: Wed Jan 16 10:23:45 2019 -0700 Replace AuthorizedIndices class with a List (elastic#37328) This change replaces the AuthorizedIndices class with a simple list. The change to a simple list does remove the lazy loading of the authorized indices in favor of simpler code as the loading of this list is now an asynchronous operation that is delegated to the authorization engine. commit 0246442 Merge: 8ccdc19 a2a40c5 Author: jaymode <[email protected]> Date: Tue Jan 15 10:49:12 2019 -0700 Merge branch 'master' into security_authz_engine commit 8ccdc19 Author: Jay Modi <[email protected]> Date: Mon Jan 7 13:43:22 2019 -0700 Introduce asynchronous RBACEngine (elastic#36245) In order to support the concept of different authorization engines, this change begins the refactoring of the AuthorizationService to support this. Previously, the asynchronous work for authorization was performed by the AsyncAuthorizer class, but this tied the authorization service to a role based implementation. In this change, the authorize method become asynchronous and delegates much of the actual permission checking to an AuthorizationEngine. The pre-existing RBAC permission checking has been abstracted into the RBACEngine. The majority of calls to AuthorizationEngine instances are asynchronous as the underlying implementation may need to make network calls that should not block the current thread, which are often network threads. This change is meant to be built upon. The basic concepts are introduced without proper documentation, plumbing to enable other AuthorizationEngine types, and some items we may want to refactor. For example, the AuthorizedIndices class is lazily loaded but this might actually be something we want to make asynchronous. We pass a lot of the same arguments to the various methods and it would be prudent to wrap these in a class; this class would provide a way for us to pass additional items needed by future enhancements without breaking the interface and requiring updates to all implementations. See elastic#32435
1 parent 66530db commit b15f680

File tree

68 files changed

+4480
-2512
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+4480
-2512
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ allprojects {
233233
"org.elasticsearch.plugin:aggs-matrix-stats-client:${version}": ':modules:aggs-matrix-stats',
234234
"org.elasticsearch.plugin:percolator-client:${version}": ':modules:percolator',
235235
"org.elasticsearch.plugin:rank-eval-client:${version}": ':modules:rank-eval',
236+
// for security example plugins
237+
"org.elasticsearch.plugin:x-pack-core:${version}": ':x-pack:plugin:core',
238+
"org.elasticsearch.client.x-pack-transport:${version}": ':x-pack:transport-client'
236239
]
237240

238241
/*
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
apply plugin: 'elasticsearch.esplugin'
2+
3+
esplugin {
4+
name 'security-authorization-engine'
5+
description 'An example spi extension plugin for security that implements an Authorization Engine'
6+
classname 'org.elasticsearch.example.AuthorizationEnginePlugin'
7+
extendedPlugins = ['x-pack-security']
8+
}
9+
10+
dependencies {
11+
compileOnly "org.elasticsearch.plugin:x-pack-core:${version}"
12+
testCompile "org.elasticsearch.client.x-pack-transport:${version}"
13+
}
14+
15+
16+
integTestRunner {
17+
systemProperty 'tests.security.manager', 'false'
18+
}
19+
20+
integTestCluster {
21+
dependsOn buildZip
22+
setting 'xpack.security.enabled', 'true'
23+
setting 'xpack.ilm.enabled', 'false'
24+
setting 'xpack.ml.enabled', 'false'
25+
setting 'xpack.monitoring.enabled', 'false'
26+
setting 'xpack.license.self_generated.type', 'trial'
27+
28+
// This is important, so that all the modules are available too.
29+
// There are index templates that use token filters that are in analysis-module and
30+
// processors are being used that are in ingest-common module.
31+
distribution = 'default'
32+
33+
setupCommand 'setupDummyUser',
34+
'bin/elasticsearch-users', 'useradd', 'test_user', '-p', 'x-pack-test-password', '-r', 'custom_superuser'
35+
waitCondition = { node, ant ->
36+
File tmpFile = new File(node.cwd, 'wait.success')
37+
ant.get(src: "http://${node.httpUri()}/_cluster/health?wait_for_nodes=>=${numNodes}&wait_for_status=yellow",
38+
dest: tmpFile.toString(),
39+
username: 'test_user',
40+
password: 'x-pack-test-password',
41+
ignoreerrors: true,
42+
retries: 10)
43+
return tmpFile.exists()
44+
}
45+
}
46+
check.dependsOn integTest
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.example;
21+
22+
import org.elasticsearch.plugins.ActionPlugin;
23+
import org.elasticsearch.plugins.Plugin;
24+
25+
/**
26+
* Plugin class that is required so that the code contained here may be loaded as a plugin.
27+
* Additional items such as settings and actions can be registered using this plugin class.
28+
*/
29+
public class AuthorizationEnginePlugin extends Plugin implements ActionPlugin {
30+
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.example;
21+
22+
import org.elasticsearch.action.ActionListener;
23+
import org.elasticsearch.cluster.metadata.AliasOrIndex;
24+
import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesRequest;
25+
import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesResponse;
26+
import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesResponse.Indices;
27+
import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesRequest;
28+
import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesResponse;
29+
import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesResponse.ResourcePrivileges;
30+
import org.elasticsearch.xpack.core.security.authc.Authentication;
31+
import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine;
32+
import org.elasticsearch.xpack.core.security.authz.ResolvedIndices;
33+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
34+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor.IndicesPrivileges;
35+
import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl;
36+
import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl.IndexAccessControl;
37+
import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissions;
38+
import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor;
39+
import org.elasticsearch.xpack.core.security.authz.privilege.ConditionalClusterPrivilege;
40+
import org.elasticsearch.xpack.core.security.user.User;
41+
42+
import java.util.ArrayList;
43+
import java.util.Arrays;
44+
import java.util.Collection;
45+
import java.util.Collections;
46+
import java.util.HashMap;
47+
import java.util.LinkedHashMap;
48+
import java.util.List;
49+
import java.util.Map;
50+
import java.util.Set;
51+
import java.util.stream.Collectors;
52+
53+
/**
54+
* A custom implementation of an authorization engine. This engine is extremely basic in that it
55+
* authorizes based upon the name of a single role. If users have this role they are granted access.
56+
*/
57+
public class CustomAuthorizationEngine implements AuthorizationEngine {
58+
59+
@Override
60+
public void resolveAuthorizationInfo(RequestInfo requestInfo, ActionListener<AuthorizationInfo> listener) {
61+
final Authentication authentication = requestInfo.getAuthentication();
62+
if (authentication.getUser().isRunAs()) {
63+
final CustomAuthorizationInfo authenticatedUserAuthzInfo =
64+
new CustomAuthorizationInfo(authentication.getUser().authenticatedUser().roles(), null);
65+
listener.onResponse(new CustomAuthorizationInfo(authentication.getUser().roles(), authenticatedUserAuthzInfo));
66+
} else {
67+
listener.onResponse(new CustomAuthorizationInfo(authentication.getUser().roles(), null));
68+
}
69+
}
70+
71+
@Override
72+
public void authorizeRunAs(RequestInfo requestInfo, AuthorizationInfo authorizationInfo, ActionListener<AuthorizationResult> listener) {
73+
if (isSuperuser(requestInfo.getAuthentication().getUser().authenticatedUser())) {
74+
listener.onResponse(AuthorizationResult.granted());
75+
} else {
76+
listener.onResponse(AuthorizationResult.deny());
77+
}
78+
}
79+
80+
@Override
81+
public void authorizeClusterAction(RequestInfo requestInfo, AuthorizationInfo authorizationInfo,
82+
ActionListener<AuthorizationResult> listener) {
83+
if (isSuperuser(requestInfo.getAuthentication().getUser())) {
84+
listener.onResponse(AuthorizationResult.granted());
85+
} else {
86+
listener.onResponse(AuthorizationResult.deny());
87+
}
88+
}
89+
90+
@Override
91+
public void authorizeIndexAction(RequestInfo requestInfo, AuthorizationInfo authorizationInfo,
92+
AsyncSupplier<ResolvedIndices> indicesAsyncSupplier,
93+
Map<String, AliasOrIndex> aliasOrIndexLookup,
94+
ActionListener<IndexAuthorizationResult> listener) {
95+
if (isSuperuser(requestInfo.getAuthentication().getUser())) {
96+
indicesAsyncSupplier.getAsync(ActionListener.wrap(resolvedIndices -> {
97+
Map<String, IndexAccessControl> indexAccessControlMap = new HashMap<>();
98+
for (String name : resolvedIndices.getLocal()) {
99+
indexAccessControlMap.put(name, new IndexAccessControl(true, FieldPermissions.DEFAULT, null));
100+
}
101+
IndicesAccessControl indicesAccessControl =
102+
new IndicesAccessControl(true, Collections.unmodifiableMap(indexAccessControlMap));
103+
listener.onResponse(new IndexAuthorizationResult(true, indicesAccessControl));
104+
}, listener::onFailure));
105+
} else {
106+
listener.onResponse(new IndexAuthorizationResult(true, IndicesAccessControl.DENIED));
107+
}
108+
}
109+
110+
@Override
111+
public void loadAuthorizedIndices(RequestInfo requestInfo, AuthorizationInfo authorizationInfo,
112+
Map<String, AliasOrIndex> aliasOrIndexLookup, ActionListener<List<String>> listener) {
113+
if (isSuperuser(requestInfo.getAuthentication().getUser())) {
114+
listener.onResponse(new ArrayList<>(aliasOrIndexLookup.keySet()));
115+
} else {
116+
listener.onResponse(Collections.emptyList());
117+
}
118+
}
119+
120+
@Override
121+
public void validateIndexPermissionsAreSubset(RequestInfo requestInfo, AuthorizationInfo authorizationInfo,
122+
Map<String, List<String>> indexNameToNewNames,
123+
ActionListener<AuthorizationResult> listener) {
124+
if (isSuperuser(requestInfo.getAuthentication().getUser())) {
125+
listener.onResponse(AuthorizationResult.granted());
126+
} else {
127+
listener.onResponse(AuthorizationResult.deny());
128+
}
129+
}
130+
131+
@Override
132+
public void checkPrivileges(Authentication authentication, AuthorizationInfo authorizationInfo,
133+
HasPrivilegesRequest hasPrivilegesRequest,
134+
Collection<ApplicationPrivilegeDescriptor> applicationPrivilegeDescriptors,
135+
ActionListener<HasPrivilegesResponse> listener) {
136+
if (isSuperuser(authentication.getUser())) {
137+
listener.onResponse(getHasPrivilegesResponse(authentication, hasPrivilegesRequest, true));
138+
} else {
139+
listener.onResponse(getHasPrivilegesResponse(authentication, hasPrivilegesRequest, false));
140+
}
141+
}
142+
143+
@Override
144+
public void getUserPrivileges(Authentication authentication, AuthorizationInfo authorizationInfo, GetUserPrivilegesRequest request,
145+
ActionListener<GetUserPrivilegesResponse> listener) {
146+
if (isSuperuser(authentication.getUser())) {
147+
listener.onResponse(getUserPrivilegesResponse(true));
148+
} else {
149+
listener.onResponse(getUserPrivilegesResponse(false));
150+
}
151+
}
152+
153+
private HasPrivilegesResponse getHasPrivilegesResponse(Authentication authentication, HasPrivilegesRequest hasPrivilegesRequest,
154+
boolean authorized) {
155+
Map<String, Boolean> clusterPrivMap = new HashMap<>();
156+
for (String clusterPriv : hasPrivilegesRequest.clusterPrivileges()) {
157+
clusterPrivMap.put(clusterPriv, authorized);
158+
}
159+
final Map<String, ResourcePrivileges> indices = new LinkedHashMap<>();
160+
for (IndicesPrivileges check : hasPrivilegesRequest.indexPrivileges()) {
161+
for (String index : check.getIndices()) {
162+
final Map<String, Boolean> privileges = new HashMap<>();
163+
final HasPrivilegesResponse.ResourcePrivileges existing = indices.get(index);
164+
if (existing != null) {
165+
privileges.putAll(existing.getPrivileges());
166+
}
167+
for (String privilege : check.getPrivileges()) {
168+
privileges.put(privilege, authorized);
169+
}
170+
indices.put(index, new ResourcePrivileges(index, privileges));
171+
}
172+
}
173+
final Map<String, Collection<ResourcePrivileges>> privilegesByApplication = new HashMap<>();
174+
Set<String> applicationNames = Arrays.stream(hasPrivilegesRequest.applicationPrivileges())
175+
.map(RoleDescriptor.ApplicationResourcePrivileges::getApplication)
176+
.collect(Collectors.toSet());
177+
for (String applicationName : applicationNames) {
178+
final Map<String, HasPrivilegesResponse.ResourcePrivileges> appPrivilegesByResource = new LinkedHashMap<>();
179+
for (RoleDescriptor.ApplicationResourcePrivileges p : hasPrivilegesRequest.applicationPrivileges()) {
180+
if (applicationName.equals(p.getApplication())) {
181+
for (String resource : p.getResources()) {
182+
final Map<String, Boolean> privileges = new HashMap<>();
183+
final HasPrivilegesResponse.ResourcePrivileges existing = appPrivilegesByResource.get(resource);
184+
if (existing != null) {
185+
privileges.putAll(existing.getPrivileges());
186+
}
187+
for (String privilege : p.getPrivileges()) {
188+
privileges.put(privilege, authorized);
189+
}
190+
appPrivilegesByResource.put(resource, new HasPrivilegesResponse.ResourcePrivileges(resource, privileges));
191+
}
192+
}
193+
}
194+
privilegesByApplication.put(applicationName, appPrivilegesByResource.values());
195+
}
196+
return new HasPrivilegesResponse(authentication.getUser().principal(), authorized, clusterPrivMap, indices.values(),
197+
privilegesByApplication);
198+
}
199+
200+
private GetUserPrivilegesResponse getUserPrivilegesResponse(boolean isSuperuser) {
201+
final Set<String> cluster = isSuperuser ? Collections.singleton("ALL") : Collections.emptySet();
202+
final Set<ConditionalClusterPrivilege> conditionalCluster = Collections.emptySet();
203+
final Set<GetUserPrivilegesResponse.Indices> indices = isSuperuser ? Collections.singleton(new Indices(Collections.singleton("*"),
204+
Collections.singleton("*"), Collections.emptySet(), Collections.emptySet(), true)) : Collections.emptySet();
205+
206+
final Set<RoleDescriptor.ApplicationResourcePrivileges> application = isSuperuser ?
207+
Collections.singleton(
208+
RoleDescriptor.ApplicationResourcePrivileges.builder().application("*").privileges("*").resources("*").build()) :
209+
Collections.emptySet();
210+
final Set<String> runAs = isSuperuser ? Collections.singleton("*") : Collections.emptySet();
211+
return new GetUserPrivilegesResponse(cluster, conditionalCluster, indices, application, runAs);
212+
}
213+
214+
public static class CustomAuthorizationInfo implements AuthorizationInfo {
215+
216+
private final String[] roles;
217+
private final CustomAuthorizationInfo authenticatedAuthzInfo;
218+
219+
CustomAuthorizationInfo(String[] roles, CustomAuthorizationInfo authenticatedAuthzInfo) {
220+
this.roles = roles;
221+
this.authenticatedAuthzInfo = authenticatedAuthzInfo;
222+
}
223+
224+
@Override
225+
public Map<String, Object> asMap() {
226+
return Collections.singletonMap("roles", roles);
227+
}
228+
229+
@Override
230+
public CustomAuthorizationInfo getAuthenticatedUserAuthorizationInfo() {
231+
return authenticatedAuthzInfo;
232+
}
233+
}
234+
235+
private boolean isSuperuser(User user) {
236+
return Arrays.binarySearch(user.roles(), "custom_superuser") > -1;
237+
}
238+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.example;
21+
22+
import org.elasticsearch.common.settings.Settings;
23+
import org.elasticsearch.xpack.core.security.SecurityExtension;
24+
import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine;
25+
26+
/**
27+
* Security extension class that registers the custom authorization engine to be used
28+
*/
29+
public class ExampleAuthorizationEngineExtension implements SecurityExtension {
30+
31+
@Override
32+
public AuthorizationEngine getAuthorizationEngine(Settings settings) {
33+
return new CustomAuthorizationEngine();
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
org.elasticsearch.example.ExampleAuthorizationEngineExtension

0 commit comments

Comments
 (0)