Skip to content
5 changes: 5 additions & 0 deletions docs/changelog/94931.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 94931
summary: Reuse `FieldPermissionsCache` in Role parsing
area: Authorization
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xcontent.json.JsonXContent;
import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.PrivilegesToCheck;
import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissions;
import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache;
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges;
import org.elasticsearch.xpack.core.security.support.Validation;
Expand Down Expand Up @@ -857,7 +857,7 @@ private static ConfigurableClusterPrivilege[] sortConfigurableClusterPrivileges(

private static void checkIfExceptFieldsIsSubsetOfGrantedFields(String roleName, String[] grantedFields, String[] deniedFields) {
try {
FieldPermissions.buildPermittedFieldsAutomaton(grantedFields, deniedFields);
FieldPermissionsCache.resolve(grantedFields, deniedFields);
} catch (ElasticsearchSecurityException e) {
throw new ElasticsearchParseException("failed to parse indices privileges for role [{}] - {}", e, roleName, e.getMessage());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
package org.elasticsearch.xpack.core.security.authz.permission;

import org.apache.lucene.util.SetOnce;
import org.apache.lucene.util.automaton.Automaton;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.common.cache.Cache;
Expand Down Expand Up @@ -33,6 +34,8 @@
*/
public final class FieldPermissionsCache {

private static final SetOnce<FieldPermissionsCache> INSTANCE = new SetOnce<>();

public static final Setting<Long> CACHE_SIZE_SETTING = Setting.longSetting(
setting("authz.store.roles.field_permissions.cache.max_size_in_bytes"),
100 * 1024 * 1024,
Expand All @@ -41,13 +44,37 @@ public final class FieldPermissionsCache {
);
private final Cache<FieldPermissionsDefinition, FieldPermissions> cache;

public static FieldPermissionsCache init(Settings settings) {
final FieldPermissionsCache cache = new FieldPermissionsCache(settings);
INSTANCE.set(cache);
return cache;
Copy link
Member

Choose a reason for hiding this comment

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

It feels a bit odd to have both a staticly shared instance and a public constructor.

}

public static FieldPermissionsCache getCache() {
return INSTANCE.get();
}

public Cache.CacheStats getCacheStats() {
return cache.stats();
}

public FieldPermissionsCache(Settings settings) {
this.cache = CacheBuilder.<FieldPermissionsDefinition, FieldPermissions>builder()
.setMaximumWeight(CACHE_SIZE_SETTING.get(settings))
.weigher((key, fieldPermissions) -> fieldPermissions.ramBytesUsed())
.build();
}

public static FieldPermissions resolve(String[] grantedFields, String[] deniedFields) {
final FieldPermissionsDefinition definition = new FieldPermissionsDefinition(grantedFields, deniedFields);
final FieldPermissionsCache cache = INSTANCE.get();
if (cache == null) {
return new FieldPermissions(definition);
Copy link
Member

Choose a reason for hiding this comment

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

We don't allow this cache to be disabled. So this should never be null. We should either assert that or throw error. Also this constructor is currently only used in tests because that's where cache may not exist. With this new method, it is possible to replace the constructor usages with init and this method and drop this constructor entirely and I think that would be a good thing.

} else {
return cache.getFieldPermissions(definition);
}
}

/**
* Gets a {@link FieldPermissions} instance that corresponds to the granted and denied parameters. The instance may come from the cache
* or if it gets created, the instance will be cached
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.cache.Cache;
import org.elasticsearch.common.io.stream.ByteBufferStreamInput;
import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.set.Sets;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.test.ESTestCase;
Expand All @@ -27,6 +29,7 @@
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xpack.core.XPackClientPlugin;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor.ApplicationResourcePrivileges;
import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache;
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges;
Expand Down Expand Up @@ -394,6 +397,52 @@ public void testParse() throws Exception {
assertThat(ex.getMessage(), containsString("not_supported"));
}

public void testParsingFieldPermissionsUsesCache() throws IOException {
FieldPermissionsCache fieldPermissionsCache = FieldPermissionsCache.getCache();
if (fieldPermissionsCache == null) {
fieldPermissionsCache = FieldPermissionsCache.init(Settings.EMPTY);
}

final Cache.CacheStats beforeStats = fieldPermissionsCache.getCacheStats();

final String json = """
{
"index": [
{
"names": "index-001",
"privileges": [ "read" ],
"field_security": {
"grant": [ "field-001", "field-002" ]
}
},
{
"names": "index-001",
"privileges": [ "read" ],
"field_security": {
"grant": [ "*" ],
"except": [ "field-003" ]
}
}
]
}
""";
RoleDescriptor.parse("test", new BytesArray(json), false, XContentType.JSON);

final int numberOfFieldSecurityBlocks = 2;
final Cache.CacheStats betweenStats = fieldPermissionsCache.getCacheStats();
assertThat(betweenStats.getMisses(), equalTo(beforeStats.getMisses() + numberOfFieldSecurityBlocks));
assertThat(betweenStats.getHits(), equalTo(beforeStats.getHits()));

final int iterations = randomIntBetween(1, 5);
for (int i = 0; i < iterations; i++) {
RoleDescriptor.parse("test", new BytesArray(json), false, XContentType.JSON);
}

final Cache.CacheStats afterStats = fieldPermissionsCache.getCacheStats();
assertThat(afterStats.getMisses(), equalTo(betweenStats.getMisses()));
assertThat(afterStats.getHits(), equalTo(beforeStats.getHits() + numberOfFieldSecurityBlocks * iterations));
}

public void testSerializationForCurrentVersion() throws Exception {
final TransportVersion version = TransportVersionUtils.randomCompatibleVersion(random());
final boolean canIncludeRemoteIndices = version.onOrAfter(RoleDescriptor.TRANSPORT_VERSION_REMOTE_INDICES);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ Collection<Object> createComponents(
components.add(privilegeStore);

dlsBitsetCache.set(new DocumentSubsetBitsetCache(settings, threadPool));
final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(settings);
final FieldPermissionsCache fieldPermissionsCache = FieldPermissionsCache.init(settings);
final FileRolesStore fileRolesStore = new FileRolesStore(
settings,
environment,
Expand Down Expand Up @@ -910,6 +910,7 @@ Collection<Object> createComponents(
final AuthorizationService authzService = new AuthorizationService(
settings,
allRolesStore,
fieldPermissionsCache,
Copy link
Member

Choose a reason for hiding this comment

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

Do we want to make this change? The cache used by RBACEngine is techinically cross different index groups and the combination depends on the requested indices patterns, e.g. if we have

"indices": [ { "name": ["logs-*"], "privileges": ["read"], "field_security": {"grant": ["xxx"] } }, { "name": ["*-staging"], "privileges": ["read"], "field_security": {"grant": ["yyy"] } }, ] 

If the requested index pattern is logs-nginx-staging, both groups will match and the cache key will be

new FieldPermissionsDefinition( Set.of( new FieldGrantExcludeGroup(["xxx"], null), new FieldGrantExcludeGroup(["yyy"], null) ) ) 

While the cache used in CompositeRolesStore is most likely keyed by a single index group. With the above example, the two groups will be cached separately. If we combine the two caches, it is possible that more entries would be competing for cache usages.

Along the same line of reasoning, the cache used by RoleDescriptor is purely for a single index definition. Strictly speaking, it can also be different from the cache usage by CompositeRolesStore which can combine index groups if they have exactly the same names definition.

In summary, the cache is keyed slightly different with the 3 use cases:

  1. RoleDescriptor - keyed strictly by a single index group's FLS definition
  2. CompositeRolesStore - keyed by FLS combination of multiple index groups that share the same names definition
  3. RBACEngine - keyed by FLS combination of mulitple index groups that have names matching the requested index.

That said, I do think having a single cache for them is reasonable because we don't really want to differentiate clearly between the different type of cache keys. My only concern is whether combining them could add extra noticeable pressure on this single cache.

clusterService,
auditTrailService,
failureHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
import org.elasticsearch.xpack.core.security.authz.RestrictedIndices;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection;
import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl;
import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache;
import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor;
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
Expand Down Expand Up @@ -140,6 +141,7 @@ public class AuthorizationService {
public AuthorizationService(
Settings settings,
CompositeRolesStore rolesStore,
FieldPermissionsCache fieldPermissionsCache,
ClusterService clusterService,
AuditTrailService auditTrailService,
AuthenticationFailureHandler authcFailureHandler,
Expand All @@ -165,6 +167,7 @@ public AuthorizationService(
this.rbacEngine = new RBACEngine(
settings,
rolesStore,
fieldPermissionsCache,
new LoadAuthorizedIndicesTimeChecker.Factory(logger, settings, clusterService.getClusterSettings())
);
this.authorizationEngine = authorizationEngine == null ? this.rbacEngine : authorizationEngine;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,12 @@ public class RBACEngine implements AuthorizationEngine {
public RBACEngine(
Settings settings,
CompositeRolesStore rolesStore,
FieldPermissionsCache fieldPermissionsCache,
LoadAuthorizedIndicesTimeChecker.Factory authzIndicesTimerFactory
) {
this.settings = settings;
this.rolesStore = rolesStore;
this.fieldPermissionsCache = new FieldPermissionsCache(settings);
this.fieldPermissionsCache = fieldPermissionsCache;
this.authzIndicesTimerFactory = authzIndicesTimerFactory;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ public class AuthorizationServiceTests extends ESTestCase {
private ThreadPool threadPool;
private Map<String, RoleDescriptor> roleMap = new HashMap<>();
private CompositeRolesStore rolesStore;
private FieldPermissionsCache fieldPermissionsCache;
private OperatorPrivileges.OperatorPrivilegesService operatorPrivilegesService;
private boolean shouldFailOperatorPrivilegesCheck = false;
private boolean setFakeOriginatingAction = true;
Expand All @@ -245,6 +246,7 @@ public class AuthorizationServiceTests extends ESTestCase {
@SuppressWarnings("unchecked")
@Before
public void setup() {
fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY);
rolesStore = mock(CompositeRolesStore.class);
clusterService = mock(ClusterService.class);
final Settings settings = Settings.builder().put("cluster.remote.other_cluster.seeds", "localhost:9999").build();
Expand Down Expand Up @@ -292,6 +294,7 @@ public void setup() {
authorizationService = new AuthorizationService(
settings,
rolesStore,
fieldPermissionsCache,
clusterService,
auditTrailService,
new DefaultAuthenticationFailureHandler(Collections.emptyMap()),
Expand Down Expand Up @@ -1626,6 +1629,7 @@ public void testDenialForAnonymousUser() {
authorizationService = new AuthorizationService(
settings,
rolesStore,
fieldPermissionsCache,
clusterService,
auditTrailService,
new DefaultAuthenticationFailureHandler(Collections.emptyMap()),
Expand Down Expand Up @@ -1673,6 +1677,7 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() {
authorizationService = new AuthorizationService(
settings,
rolesStore,
fieldPermissionsCache,
clusterService,
auditTrailService,
new DefaultAuthenticationFailureHandler(Collections.emptyMap()),
Expand Down Expand Up @@ -2768,6 +2773,7 @@ public void testAuthorizationEngineSelectionForCheckPrivileges() throws Exceptio
authorizationService = new AuthorizationService(
Settings.EMPTY,
rolesStore,
fieldPermissionsCache,
clusterService,
auditTrailService,
new DefaultAuthenticationFailureHandler(Collections.emptyMap()),
Expand Down Expand Up @@ -2922,6 +2928,7 @@ public void getUserPrivileges(AuthorizationInfo authorizationInfo, ActionListene
authorizationService = new AuthorizationService(
Settings.EMPTY,
rolesStore,
fieldPermissionsCache,
clusterService,
auditTrailService,
new DefaultAuthenticationFailureHandler(Collections.emptyMap()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public void createEngine() {
final LoadAuthorizedIndicesTimeChecker.Factory timerFactory = mock(LoadAuthorizedIndicesTimeChecker.Factory.class);
when(timerFactory.newTimer(any())).thenReturn(LoadAuthorizedIndicesTimeChecker.NO_OP_CONSUMER);
rolesStore = mock(CompositeRolesStore.class);
engine = new RBACEngine(Settings.EMPTY, rolesStore, timerFactory);
engine = new RBACEngine(Settings.EMPTY, rolesStore, new FieldPermissionsCache(Settings.EMPTY), timerFactory);
}

public void testResolveAuthorizationInfoForEmptyRolesWithAuthentication() {
Expand Down