Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
787c1d7
Role changes to support enforcing workflow restrictions
slobodanadamovic Jun 9, 2023
1f89678
Change role filtering to explicit workflow restriction checking
slobodanadamovic Jun 13, 2023
35e5af6
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 13, 2023
6db9f8d
Implement build role from role descriptor for workflows
slobodanadamovic Jun 13, 2023
d215be5
Add assertions
slobodanadamovic Jun 13, 2023
cd110aa
Add more tests
slobodanadamovic Jun 13, 2023
d55c90b
Change modifier to private
slobodanadamovic Jun 13, 2023
ebe8fbc
Update assertion
slobodanadamovic Jun 13, 2023
10b69a1
Remove unused parameter
slobodanadamovic Jun 13, 2023
613191a
Test restriction with search application query API
slobodanadamovic Jun 13, 2023
69c93da
Implement role filtering approach
slobodanadamovic Jun 14, 2023
e8c342d
name nit
slobodanadamovic Jun 14, 2023
2ff0f25
Apply spotless
slobodanadamovic Jun 14, 2023
84ac3ea
Simplify role filtering
slobodanadamovic Jun 14, 2023
8516933
Fail early - immediately after authz info resolution
slobodanadamovic Jun 14, 2023
2058f5e
Check limitedByRole if it's restricted. Change assert to IAE
slobodanadamovic Jun 15, 2023
e6ea8cd
Address review feedback
slobodanadamovic Jun 15, 2023
50ff3af
Fix failing tests
slobodanadamovic Jun 15, 2023
7e51ffa
Fail authz info resolving instead of returning denied object
slobodanadamovic Jun 15, 2023
f6d729e
Revert accidentally removed check
slobodanadamovic Jun 15, 2023
8346abd
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 15, 2023
d0b2927
Change assert to IAE
slobodanadamovic Jun 15, 2023
8cebf42
Fix failing test.
slobodanadamovic Jun 15, 2023
6fe921e
Change back to assertion
slobodanadamovic Jun 15, 2023
5c63c7a
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 15, 2023
f9ea677
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 15, 2023
5a36c1c
Fix condition check
slobodanadamovic Jun 15, 2023
c6b7beb
Address review fedback
slobodanadamovic Jun 16, 2023
a2773c0
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 16, 2023
4df1280
Fix failing test
slobodanadamovic Jun 16, 2023
95fa2e3
Remove unnecessary resolve method. Update javadoc.
slobodanadamovic Jun 16, 2023
0db7341
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 16, 2023
ac42c70
Add a TODO for future reference
slobodanadamovic Jun 16, 2023
490e6a9
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 18, 2023
1c6df81
Address review feedback:
slobodanadamovic Jun 19, 2023
8423e81
Apply spotless.
slobodanadamovic Jun 19, 2023
686a1ed
Add tests for CompositeRolesStore.getRole
slobodanadamovic Jun 19, 2023
61eac92
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 19, 2023
2f3128a
Fix failing tests by spying on real instance to avoid that callRealMe…
slobodanadamovic Jun 19, 2023
4f87f58
Add debug log.
slobodanadamovic Jun 19, 2023
cef1633
Test RBACEngine.resolveAuthorizationInfo
slobodanadamovic Jun 19, 2023
a83aa3e
Test AuthorizationService access denial
slobodanadamovic Jun 19, 2023
339ad06
Apply review comments to ApiKeyWorkflowsRestrictionRestIT
slobodanadamovic Jun 19, 2023
5272341
Merge branch 'main' of github.com:elastic/elasticsearch into sa-workf…
slobodanadamovic Jun 19, 2023
2b13abb
Update docs/changelog/96744.yaml
slobodanadamovic Jun 19, 2023
629ab87
Update docs/changelog/96744.yaml
slobodanadamovic Jun 19, 2023
File filter

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/96744.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 96744
summary: Support restricting access of API keys to only certain workflows
area: Authorization
type: feature
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -1838,6 +1838,12 @@ private enum ElasticsearchExceptionHandle {
org.elasticsearch.http.HttpHeadersValidationException::new,
169,
TransportVersion.V_8_9_0
),
ROLE_RESTRICTION_EXCEPTION(
ElasticsearchRoleRestrictionException.class,
ElasticsearchRoleRestrictionException::new,
170,
TransportVersion.V_8_500_016
);

final Class<? extends ElasticsearchException> exceptionClass;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

package org.elasticsearch;

import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.rest.RestStatus;

import java.io.IOException;

/**
* This exception is thrown to indicate that the access has been denied because of role restrictions that
* an authenticated subject might have (e.g. not allowed to access certain APIs).
* This differs from other 403 error in sense that it's additional access control that is enforced after role
* is resolved and before permissions are checked.
*/
public class ElasticsearchRoleRestrictionException extends ElasticsearchSecurityException {

public ElasticsearchRoleRestrictionException(String msg, Throwable cause, Object... args) {
super(msg, RestStatus.FORBIDDEN, cause, args);
}

public ElasticsearchRoleRestrictionException(String msg, Object... args) {
this(msg, null, args);
}

public ElasticsearchRoleRestrictionException(StreamInput in) throws IOException {
super(in);
}
}
3 changes: 2 additions & 1 deletion server/src/main/java/org/elasticsearch/TransportVersion.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ private static TransportVersion registerTransportVersion(int id, String uniqueId
public static final TransportVersion V_8_500_013 = registerTransportVersion(8_500_013, "f65b85ac-db5e-4558-a487-a1dde4f6a33a");
public static final TransportVersion V_8_500_014 = registerTransportVersion(8_500_014, "D115A2E1-1739-4A02-AB7B-64F6EA157EFB");
public static final TransportVersion V_8_500_015 = registerTransportVersion(8_500_015, "651216c9-d54f-4189-9fe1-48d82d276863");
public static final TransportVersion V_8_500_016 = registerTransportVersion(8_500_016, "492C94FB-AAEA-4C9E-8375-BDB67A398584");

private static class CurrentHolder {
private static final TransportVersion CURRENT = findCurrent(V_8_500_015);
private static final TransportVersion CURRENT = findCurrent(V_8_500_016);

// finds the pluggable current version, or uses the given fallback
private static TransportVersion findCurrent(TransportVersion fallback) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,7 @@ public void testIds() {
ids.put(167, UnsupportedAggregationOnDownsampledIndex.class);
ids.put(168, DocumentParsingException.class);
ids.put(169, HttpHeadersValidationException.class);
ids.put(170, ElasticsearchRoleRestrictionException.class);

Map<Class<? extends ElasticsearchException>, Integer> reverse = new HashMap<>();
for (Map.Entry<Integer, Class<? extends ElasticsearchException>> entry : ids.entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public final class LimitedRole implements Role {
public LimitedRole(Role baseRole, Role limitedByRole) {
this.baseRole = Objects.requireNonNull(baseRole);
this.limitedByRole = Objects.requireNonNull(limitedByRole, "limited by role is required to create limited role");
assert false == limitedByRole.hasWorkflowsRestriction() : "limited-by role must not have workflows restriction";
}

@Override
Expand All @@ -74,6 +75,28 @@ public RemoteIndicesPermission remoteIndices() {
throw new UnsupportedOperationException("cannot retrieve remote indices permission on limited role");
}

@Override
public boolean hasWorkflowsRestriction() {
return baseRole.hasWorkflowsRestriction() || limitedByRole.hasWorkflowsRestriction();
}

@Override
public Role forWorkflow(String workflow) {
Role baseRestricted = baseRole.forWorkflow(workflow);
if (baseRestricted == EMPTY_RESTRICTED_BY_WORKFLOW) {
return EMPTY_RESTRICTED_BY_WORKFLOW;
}
Role limitedByRestricted = limitedByRole.forWorkflow(workflow);
if (limitedByRestricted == EMPTY_RESTRICTED_BY_WORKFLOW) {
return EMPTY_RESTRICTED_BY_WORKFLOW;
}
if (baseRestricted == baseRole && limitedByRestricted == limitedByRole) {
return this;
} else {
return baseRestricted.limitedBy(limitedByRestricted);
}
}

@Override
public ApplicationPermission application() {
throw new UnsupportedOperationException("cannot retrieve application permission on limited role");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
import org.elasticsearch.xpack.core.security.authz.privilege.Privilege;
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowResolver;
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowsRestriction;
import org.elasticsearch.xpack.core.security.support.Automatons;

import java.util.ArrayList;
Expand All @@ -46,6 +48,8 @@ public interface Role {

Role EMPTY = builder(new RestrictedIndices(Automatons.EMPTY)).build();

Role EMPTY_RESTRICTED_BY_WORKFLOW = builder(new RestrictedIndices(Automatons.EMPTY)).workflows(Set.of()).build();

String[] names();

ClusterPermission cluster();
Expand All @@ -58,6 +62,19 @@ public interface Role {

RemoteIndicesPermission remoteIndices();

boolean hasWorkflowsRestriction();

/**
* This method returns an effective role for the given workflow if role has workflows restriction
* (i.e. {@link #hasWorkflowsRestriction} is true). Otherwise, this method returns an unchanged role.
*
* The returned effective role can be an {@link #EMPTY_RESTRICTED_BY_WORKFLOW} when the given workflow is
* not one of the workflows to which this role is restricted.
*
* The workflows to which a role can be restricted are static and defined in {@link WorkflowResolver}.
*/
Role forWorkflow(@Nullable String workflow);

/**
* Whether the Role has any field or document level security enabled index privileges
* @return
Expand Down Expand Up @@ -176,7 +193,7 @@ IndicesAccessControl authorize(
/***
* Creates a {@link LimitedRole} that uses this Role as base and the given role as limited-by.
*/
default LimitedRole limitedBy(Role role) {
default Role limitedBy(Role role) {
return new LimitedRole(this, role);
}

Expand All @@ -200,6 +217,7 @@ class Builder {
private final Map<Set<String>, List<IndicesPermissionGroupDefinition>> remoteGroups = new HashMap<>();
private final List<Tuple<ApplicationPrivilege, Set<String>>> applicationPrivs = new ArrayList<>();
private final RestrictedIndices restrictedIndices;
private WorkflowsRestriction workflowsRestriction = WorkflowsRestriction.NONE;

private Builder(RestrictedIndices restrictedIndices, String[] names) {
this.restrictedIndices = restrictedIndices;
Expand Down Expand Up @@ -259,6 +277,15 @@ public Builder addApplicationPrivilege(ApplicationPrivilege privilege, Set<Strin
return this;
}

public Builder workflows(Set<String> workflowNames) {
if (workflowNames == null) {
this.workflowsRestriction = WorkflowsRestriction.NONE;
} else {
this.workflowsRestriction = new WorkflowsRestriction(workflowNames);
}
return this;
}

public SimpleRole build() {
final IndicesPermission indices;
if (groups.isEmpty()) {
Expand Down Expand Up @@ -301,7 +328,7 @@ public SimpleRole build() {
final ApplicationPermission applicationPermission = applicationPrivs.isEmpty()
? ApplicationPermission.NONE
: new ApplicationPermission(applicationPrivs);
return new SimpleRole(names, cluster, indices, applicationPermission, runAs, remoteIndices);
return new SimpleRole(names, cluster, indices, applicationPermission, runAs, remoteIndices, workflowsRestriction);
}

private static class IndicesPermissionGroupDefinition {
Expand Down Expand Up @@ -392,6 +419,10 @@ static SimpleRole buildFromRoleDescriptor(
builder.runAs(new Privilege(Sets.newHashSet(rdRunAs), rdRunAs));
}

if (roleDescriptor.hasWorkflowsRestriction()) {
builder.workflows(Sets.newHashSet(roleDescriptor.getRestriction().getWorkflows()));
}

return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.elasticsearch.xpack.core.security.authz.permission.IndicesPermission.IsResourceAuthorizedPredicate;
import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor;
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilege;
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowsRestriction;

import java.util.ArrayList;
import java.util.Arrays;
Expand Down Expand Up @@ -51,21 +52,24 @@ public class SimpleRole implements Role {
private final ApplicationPermission application;
private final RunAsPermission runAs;
private final RemoteIndicesPermission remoteIndices;
private final WorkflowsRestriction workflowsRestriction;

SimpleRole(
String[] names,
ClusterPermission cluster,
IndicesPermission indices,
ApplicationPermission application,
RunAsPermission runAs,
RemoteIndicesPermission remoteIndices
RemoteIndicesPermission remoteIndices,
WorkflowsRestriction workflowsRestriction
) {
this.names = names;
this.cluster = Objects.requireNonNull(cluster);
this.indices = Objects.requireNonNull(indices);
this.application = Objects.requireNonNull(application);
this.runAs = Objects.requireNonNull(runAs);
this.remoteIndices = Objects.requireNonNull(remoteIndices);
this.workflowsRestriction = Objects.requireNonNull(workflowsRestriction);
}

@Override
Expand Down Expand Up @@ -98,6 +102,20 @@ public RemoteIndicesPermission remoteIndices() {
return remoteIndices;
}

@Override
public boolean hasWorkflowsRestriction() {
return workflowsRestriction.hasWorkflows();
}

@Override
public Role forWorkflow(String workflow) {
if (workflowsRestriction.isWorkflowAllowed(workflow)) {
return this;
} else {
return EMPTY_RESTRICTED_BY_WORKFLOW;
}
}

@Override
public boolean hasFieldOrDocumentLevelSecurity() {
return indices.hasFieldOrDocumentLevelSecurity();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.core.security.authz.restriction;

import org.elasticsearch.core.Nullable;

import java.util.Set;
import java.util.function.Predicate;

public final class WorkflowsRestriction {

/**
* Default behaviour is no restriction which allows all workflows.
*/
public static final WorkflowsRestriction NONE = new WorkflowsRestriction(null);

private final Set<String> names;
private final Predicate<String> predicate;

public WorkflowsRestriction(Set<String> names) {
this.names = names;
if (names == null) {
// No restriction, all workflows are allowed
this.predicate = name -> true;
} else if (names.isEmpty()) {
// Empty restriction, no workflow is allowed
this.predicate = name -> false;
} else {
this.predicate = name -> {
if (name == null) {
return false;
} else {
return names.contains(name);
}
};
}
}

public boolean hasWorkflows() {
return this.names != null;
}

public boolean isWorkflowAllowed(@Nullable String workflow) {
return predicate.test(workflow);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeTests;
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
import org.elasticsearch.xpack.core.security.authz.restriction.Workflow;
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowResolver;
import org.elasticsearch.xpack.core.security.support.Automatons;
import org.junit.Before;

Expand All @@ -54,6 +56,7 @@
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.sameInstance;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -767,6 +770,41 @@ public void testHasPrivilegesForIndexPatterns() {
}
}

public void testForWorkflowRestriction() {
// Test when role is restricted to the same workflow as originating workflow
{
Workflow workflow = WorkflowResolver.SEARCH_APPLICATION_QUERY_WORKFLOW;
Role baseRole = Role.builder(EMPTY_RESTRICTED_INDICES, "role-a")
.add(IndexPrivilege.READ, "index-a")
.workflows(Set.of(workflow.name()))
.build();
Role limitedBy = Role.builder(EMPTY_RESTRICTED_INDICES, "role-b").add(IndexPrivilege.READ, "index-a").build();
Role role = baseRole.limitedBy(limitedBy);
assertThat(role.hasWorkflowsRestriction(), equalTo(true));
assertThat(role.forWorkflow(workflow.name()), sameInstance(role));
}
// Test restriction when role is not restricted regardless of originating workflow
{
String originatingWorkflow = randomBoolean() ? null : WorkflowResolver.SEARCH_APPLICATION_QUERY_WORKFLOW.name();
Role baseRole = Role.builder(EMPTY_RESTRICTED_INDICES, "role-a").add(IndexPrivilege.READ, "index-a").build();
Role limitedBy = Role.builder(EMPTY_RESTRICTED_INDICES, "role-b").add(IndexPrivilege.READ, "index-a").build();
Role role = baseRole.limitedBy(limitedBy);
assertThat(role.forWorkflow(originatingWorkflow), sameInstance(role));
assertThat(role.hasWorkflowsRestriction(), equalTo(false));
}
// Test when role is restricted but originating workflow is not allowed
{
Role baseRole = Role.builder(EMPTY_RESTRICTED_INDICES, "role-a")
.add(IndexPrivilege.READ, "index-a")
.workflows(Set.of(WorkflowResolver.SEARCH_APPLICATION_QUERY_WORKFLOW.name()))
.build();
Role limitedBy = Role.builder(EMPTY_RESTRICTED_INDICES, "role-b").add(IndexPrivilege.READ, "index-a").build();
Role role = baseRole.limitedBy(limitedBy);
assertThat(role.forWorkflow(randomFrom(randomAlphaOfLength(9), null, "")), sameInstance(Role.EMPTY_RESTRICTED_BY_WORKFLOW));
assertThat(role.hasWorkflowsRestriction(), equalTo(true));
}
}

public void testGetApplicationPrivilegesByResource() {
final ApplicationPrivilege app1Read = defineApplicationPrivilege("app1", "read", "data:read/*");
final ApplicationPrivilege app1All = defineApplicationPrivilege("app1", "all", "*");
Expand Down
Loading