Implement PolicyEvaluator for #1940 (#1939)

- Add security.policyevaluator module with PolicyEvaluator based on jeasy-rules
- Add MetadataOperation type
- Change accessControl/rule schema to support ABAC based on 3 attributes - entityTag, entityType, userRole
- Update CatalogAuthorizer interface to have hasPermissions function for MetadataOperation
This commit is contained in:
Matt 2021-12-29 11:36:18 -08:00 committed by GitHub
parent 5d6f385a75
commit b8d7e2bd11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 454 additions and 62 deletions

View File

@ -319,6 +319,10 @@
<groupId>com.lmax</groupId>
<artifactId>disruptor</artifactId>
</dependency>
<dependency>
<groupId>org.jeasy</groupId>
<artifactId>easy-rules-core</artifactId>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,80 @@
package org.openmetadata.catalog.security.policyevaluator;
import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
import org.jeasy.rules.api.Facts;
import org.openmetadata.catalog.entity.teams.User;
import org.openmetadata.catalog.type.EntityReference;
import org.openmetadata.catalog.type.MetadataOperation;
import org.openmetadata.catalog.type.TagLabel;
@Slf4j
@Builder(setterPrefix = "with")
class AttributeBasedFacts {
private User user;
private Object entity;
private MetadataOperation operation;
// Do not allow anything external or the builder itself change the value of facts.
// Individual Fact(s) within facts may be changed by the RulesEngine.
private final Facts facts = new Facts();
/**
* Creates {@link Facts} with the operation, user (subject) and entity (object) attributes so that it is recognizable
* by {@link org.jeasy.rules.api.RulesEngine}
*/
public Facts getFacts() throws RuntimeException {
if (!validate()) {
throw new RuntimeException("Validation failed while building facts");
}
facts.put(CommonFields.USER_ROLES, getUserRoles(user));
facts.put(CommonFields.ENTITY_TAGS, getEntityTags(entity));
facts.put(CommonFields.ENTITY_TYPE, getEntityType(entity));
facts.put(CommonFields.OPERATION, operation);
facts.put(CommonFields.ALLOW, CommonFields.DEFAULT_ACCESS);
log.debug("Generated facts successfully - {}", facts);
return facts;
}
public boolean hasPermission() {
return facts.get(CommonFields.ALLOW);
}
private List<String> getUserRoles(User user) {
// TODO: Fix this to fetch user's roles when roles is added as entity reference list from user schema.
return user.getTeams().stream().map(EntityReference::getName).collect(Collectors.toList());
}
private List<String> getEntityTags(Object entity) {
// TODO: Fix the entityType fetch such that it is fetched from Repository or using a Util.
// This is done here now to facilitate prototyping and unit testing.
List<TagLabel> entityTags = Collections.emptyList();
try {
entityTags = (List<TagLabel>) entity.getClass().getMethod("getTags").invoke(entity);
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
log.warn("Could not obtain tags for entity with class {}", entity.getClass());
}
return entityTags.stream().map(TagLabel::getTagFQN).collect(Collectors.toList());
}
private static String getEntityType(Object object) {
// TODO: Fix the entityType fetch such that it is fetched from Repository or using a Util.
// This is done here now to facilitate prototyping and unit testing.
return object.getClass().getSimpleName().toLowerCase(Locale.ROOT);
}
private boolean validate() {
log.debug(
"Validating attribute based facts - user: {}, entity: {}, operation: {}",
this.user,
this.entity,
this.operation);
return this.user != null && this.entity != null && this.operation != null;
}
}

View File

@ -0,0 +1,16 @@
package org.openmetadata.catalog.security.policyevaluator;
/**
* CommonFields defines all the fields used within the Rules and Facts for the RulesEngine used by {@link
* PolicyEvaluator}
*/
class CommonFields {
static String ALLOW = "allow";
static String ENTITY_TAGS = "entityTags";
static String ENTITY_TYPE = "entityType";
static String OPERATION = "operation";
static String USER_ROLES = "userRoles";
// By default, if no rule matches, do not grant access.
static boolean DEFAULT_ACCESS = false;
}

View File

@ -0,0 +1,68 @@
package org.openmetadata.catalog.security.policyevaluator;
import java.util.List;
import org.jeasy.rules.api.Rule;
import org.jeasy.rules.api.Rules;
import org.jeasy.rules.api.RulesEngine;
import org.jeasy.rules.api.RulesEngineParameters;
import org.jeasy.rules.core.DefaultRulesEngine;
import org.jeasy.rules.core.RuleBuilder;
import org.openmetadata.catalog.entity.teams.User;
import org.openmetadata.catalog.type.MetadataOperation;
/**
* PolicyEvaluator for {@link MetadataOperation metadata operations} based on OpenMetadata's internal {@link
* org.openmetadata.catalog.entity.policies.Policy} format to make access decisions.
*
* <p>This class uses {@link DefaultRulesEngine} provided by <a
* href="https://github.com/j-easy/easy-rules">j-easy/easy-rules</a> package.
*
* <p>The rules defined as {@link org.openmetadata.catalog.entity.policies.accessControl.Rule} are to be fetched from
* OpenMetadata's {@link org.openmetadata.catalog.jdbi3.PolicyRepository} and converted into type {@link Rule} to be
* used within the {@link DefaultRulesEngine}
*
* <p>The facts are constructed based on 3 inputs for the PolicyEvaluator:
*
* <p>- {@link MetadataOperation operation} to be performed
*
* <p>- {@link User} (subject) who performs the operation
*
* <p>- {@link org.openmetadata.catalog.Entity} (object) on which to operate on.
*/
public class PolicyEvaluator {
private final Rules rules;
private final RulesEngine rulesEngine;
public PolicyEvaluator(List<org.openmetadata.catalog.entity.policies.accessControl.Rule> rules) {
this.rules = new Rules();
rules.stream()
.filter(org.openmetadata.catalog.entity.policies.accessControl.Rule::getEnabled)
.map(this::convertRule)
.forEach(this.rules::register);
RulesEngineParameters parameters =
new RulesEngineParameters().skipOnFirstAppliedRule(true); // When first rule applies, stop the matching.
this.rulesEngine = new DefaultRulesEngine(parameters);
}
public boolean hasPermission(User user, Object entity, MetadataOperation operation) {
AttributeBasedFacts facts =
new AttributeBasedFacts.AttributeBasedFactsBuilder()
.withUser(user)
.withEntity(entity)
.withOperation(operation)
.build();
this.rulesEngine.fire(rules, facts.getFacts());
return facts.hasPermission();
}
private Rule convertRule(org.openmetadata.catalog.entity.policies.accessControl.Rule rule) {
return new RuleBuilder()
.name(rule.getName())
.description(rule.getName())
.priority(rule.getPriority())
.when(new RuleCondition(rule))
.then(new SetPermissionAction(rule))
.build();
}
}

View File

@ -0,0 +1,49 @@
package org.openmetadata.catalog.security.policyevaluator;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.jeasy.rules.api.Condition;
import org.jeasy.rules.api.Facts;
import org.openmetadata.catalog.entity.policies.accessControl.Rule;
import org.openmetadata.catalog.type.MetadataOperation;
@Slf4j
class RuleCondition implements Condition {
private final Rule rule;
public RuleCondition(Rule rule) {
this.rule = rule;
}
public boolean isValid() {
// TODO: This needs to be moved to Policy Repository rule creation, so that rules are always valid.
// At least one of the attributes must be specified in addition to the operation for a rule to be valid.
log.debug("Validating rule condition - rule: {}", rule);
return rule.getOperation() != null
&& (rule.getEntityTagAttr() != null || rule.getEntityTypeAttr() != null || rule.getUserRoleAttr() != null);
}
@Override
public boolean evaluate(Facts facts) {
// Check against operation and each of the entity and user attributes.
MetadataOperation operation = facts.get(CommonFields.OPERATION);
if (!operation.equals(rule.getOperation())) {
return false;
}
List<String> entityTags = facts.get(CommonFields.ENTITY_TAGS);
if (rule.getEntityTagAttr() != null && !entityTags.contains(rule.getEntityTagAttr())) {
return false;
}
String entityType = facts.get(CommonFields.ENTITY_TYPE);
if (rule.getEntityTypeAttr() != null && !entityType.equals(rule.getEntityTypeAttr())) {
return false;
}
List<String> userRoles = facts.get(CommonFields.USER_ROLES);
return rule.getUserRoleAttr() == null || userRoles.contains(rule.getUserRoleAttr());
}
}

View File

@ -0,0 +1,19 @@
package org.openmetadata.catalog.security.policyevaluator;
import org.jeasy.rules.api.Action;
import org.jeasy.rules.api.Facts;
import org.openmetadata.catalog.entity.policies.accessControl.Rule;
class SetPermissionAction implements Action {
private final Rule rule;
public SetPermissionAction(Rule rule) {
this.rule = rule;
}
@Override
public void execute(Facts facts) throws Exception {
facts.put(CommonFields.ALLOW, this.rule.getAllow());
}
}

View File

@ -2,34 +2,64 @@
"$id": "https://open-metadata.org/schema/entity/data/policies/accessControl/rule.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AccessControlRule",
"description": "Describes an entity Access Control Rule used within a Policy.",
"description": "Describes an Access Control Rule for OpenMetadata Metadata Operations. All non-null user (subject) and entity (object) attributes are evaluated with logical AND.",
"type": "object",
"javaType": "org.openmetadata.catalog.entity.policies.accessControl.Rule",
"definitions": {
"operation": {
"javaType": "org.openmetadata.catalog.type.MetadataOperation",
"description": "This schema defines all possible operations on metadata of data entities",
"type": "string",
"enum": [
"SuggestDescription",
"SuggestTags",
"UpdateDescription",
"UpdateOwner",
"UpdateTags"
],
"javaEnums": [
{"name":"SuggestDescription"},
{"name":"SuggestTags"},
{"name":"UpdateDescription"},
{"name":"UpdateOwner"},
{"name":"UpdateTags"}
]
}
},
"properties": {
"name": {
"description": "Name that identifies this Rule.",
"description": "Name for this Rule.",
"type": "string"
},
"prefixFilter": {
"$ref": "../filters.json#/definitions/prefix"
"entityTypeAttr": {
"description": "Entity type that the rule should match on",
"type": "string",
"default": null
},
"regexFilter": {
"$ref": "../filters.json#/definitions/regex"
"entityTagAttr": {
"description": "Entity tag that the rule should match on",
"$ref": "../../../type/tagLabel.json#/definitions/tagFQN",
"default": null
},
"tagsFilter": {
"$ref": "../filters.json#/definitions/tags"
"userRoleAttr": {
"description": "Role of the user that the rule should match on",
"$ref": "../../teams/team.json#/definitions/teamName",
"default": null
},
"actions": {
"description": "A set of access control enforcements to take on the entities.",
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{
"$ref": "tagBased.json"
}
]
}
"operation": {
"description": "Operation on the entity.",
"$ref": "#/definitions/operation",
"default": null
},
"allow": {
"description": "Allow or Deny operation on the entity.",
"type": "boolean",
"default": false
},
"priority": {
"description": "Priority of this rule among other rules in this policy.",
"type": "integer",
"default": 0
},
"enabled": {
"description": "Is the rule enabled.",
@ -38,8 +68,7 @@
}
},
"required": [
"filters",
"actions"
"name"
],
"additionalProperties": false
}

View File

@ -1,40 +0,0 @@
{
"$id": "https://open-metadata.org/schema/entity/data/policies/accessControl/tagBased.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "TagBased",
"description": "Describes an Access Control Rule to selectively grant access to Teams/Users to tagged entities.",
"type": "object",
"javaType": "org.openmetadata.catalog.entity.policies.accessControl.TagBased",
"properties": {
"tags": {
"description": "Tags that are associated with the entities.",
"type": "array",
"minItems": 1,
"items": [
{
"$ref": "../../../type/tagLabel.json"
}
]
},
"allow": {
"description": "Teams and Users who are able to access the tagged entities.",
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{
"$ref": "../../teams/team.json"
},
{
"$ref": "../../teams/user.json"
}
]
}
}
},
"required": [
"tags",
"allow"
],
"additionalProperties": false
}

View File

@ -5,10 +5,15 @@
"description": "This schema defines the type for labeling an entity with a Tag.",
"type": "object",
"javaType": "org.openmetadata.catalog.type.TagLabel",
"properties": {
"definitions": {
"tagFQN": {
"type": "string",
"maxLength": 45
}
},
"properties": {
"tagFQN": {
"$ref": "#/definitions/tagFQN"
},
"description": {
"description": "Unique name of the tag category.",

View File

@ -0,0 +1,156 @@
package org.openmetadata.catalog.security.policyevaluator;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openmetadata.catalog.entity.data.Table;
import org.openmetadata.catalog.entity.policies.accessControl.Rule;
import org.openmetadata.catalog.entity.teams.Team;
import org.openmetadata.catalog.entity.teams.User;
import org.openmetadata.catalog.jdbi3.TeamRepository;
import org.openmetadata.catalog.type.EntityReference;
import org.openmetadata.catalog.type.MetadataOperation;
import org.openmetadata.catalog.type.TagLabel;
public class PolicyEvaluatorTest {
// User Roles
private static final String DATA_CONSUMER = "DataConsumer";
private static final String DATA_STEWARD = "DataSteward";
private static final String AUDITOR = "Auditor";
private static final String LEGAL = "Legal";
private static final String DEV_OPS = "DevOps";
// Tags
private static final String PII_SENSITIVE = "PII.Sensitive";
private static Random random = new Random();
private static List<Rule> rules;
private PolicyEvaluator policyEvaluator;
@BeforeAll
static void setup() {
rules = new ArrayList<>();
rules.add(createRule(null, "table", DATA_STEWARD, MetadataOperation.UpdateOwner, true, 1, true));
rules.add(createRule(PII_SENSITIVE, null, LEGAL, MetadataOperation.UpdateTags, true, 2, true));
rules.add(createRule(PII_SENSITIVE, null, DATA_CONSUMER, MetadataOperation.SuggestTags, true, 3, true));
rules.add(createRule(null, null, DATA_CONSUMER, MetadataOperation.SuggestDescription, true, 4, true));
rules.add(createRule(null, null, DEV_OPS, MetadataOperation.UpdateTags, true, 5, false)); // disabled rule.
rules.add(createRule(null, null, DEV_OPS, MetadataOperation.UpdateTags, false, 6, true));
rules.add(createRule(null, null, DEV_OPS, MetadataOperation.UpdateDescription, false, 7, true));
rules.add(createRule(null, null, DEV_OPS, MetadataOperation.SuggestDescription, true, 8, true));
}
@BeforeEach
void beforeEach() {
Collections.shuffle(rules); // Shuffle in an attempt to throw off the PolicyEvaluator if the logic is incorrect.
policyEvaluator = new PolicyEvaluator(rules);
}
@Test
public void dataConsumer_cannot_update_owner() {
User dataConsumer = createUser(ImmutableList.of(DATA_CONSUMER));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.UpdateOwner);
assertFalse(hasPermission);
}
@Test
public void dataSteward_can_update_owner() {
User dataConsumer = createUser(ImmutableList.of(DATA_STEWARD));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.UpdateOwner);
assertTrue(hasPermission);
}
@Test
public void dataConsumer_can_suggest_description() {
User dataConsumer = createUser(ImmutableList.of(DATA_CONSUMER));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.SuggestDescription);
assertTrue(hasPermission);
}
@Test
public void legal_can_update_tags_for_pii_tables() {
User dataConsumer = createUser(ImmutableList.of(LEGAL));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.UpdateTags);
assertTrue(hasPermission);
}
@Test
public void auditor_cannot_update_tags_for_pii_tables() {
User dataConsumer = createUser(ImmutableList.of(AUDITOR));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.UpdateTags);
assertFalse(hasPermission);
}
@Test
public void devops_can_suggest_description() {
User dataConsumer = createUser(ImmutableList.of(DEV_OPS));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.SuggestDescription);
assertTrue(hasPermission);
}
@Test
public void devops_cannot_update_description() {
User dataConsumer = createUser(ImmutableList.of(DEV_OPS));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.UpdateDescription);
assertFalse(hasPermission);
}
@Test
public void devops_cannot_update_tags() {
User dataConsumer = createUser(ImmutableList.of(DEV_OPS));
Table table = createTable();
boolean hasPermission = policyEvaluator.hasPermission(dataConsumer, table, MetadataOperation.UpdateTags);
assertFalse(hasPermission);
}
private static Rule createRule(
String entityTag,
String entityType,
String userRole,
MetadataOperation operation,
boolean allow,
int priority,
boolean enabled) {
return new Rule()
.withName("rule" + random.nextInt(21)) // Create random rule name.
.withEntityTagAttr(entityTag)
.withEntityTypeAttr(entityType)
.withUserRoleAttr(userRole)
.withOperation(operation)
.withAllow(allow)
.withPriority(priority)
.withEnabled(enabled);
}
private User createUser(List<String> teamNames) {
// TODO: Use role instead of team when user schema is extended to accommodate role.
List<EntityReference> teams =
teamNames.stream()
.map(teamName -> new TeamRepository.TeamEntityInterface(new Team().withName(teamName)).getEntityReference())
.collect(Collectors.toList());
return new User().withName("John Doe").withTeams(teams);
}
private Table createTable() {
List<TagLabel> tags = new ArrayList<>();
tags.add(new TagLabel().withTagFQN(PII_SENSITIVE));
return new Table().withName("random-table").withTags(tags);
}
}

View File

@ -58,6 +58,7 @@
<wiremock-standalone.version>2.15.0</wiremock-standalone.version>
<jacoco-plugin.version>0.8.6</jacoco-plugin.version>
<jdbi3.version>3.24.1</jdbi3.version>
<jeasy.version>4.1.0</jeasy.version>
<commons-cli.version>1.5.0</commons-cli.version>
<commons-io.version>2.11.0</commons-io.version>
<flyway.version>8.0.4</flyway.version>
@ -141,6 +142,11 @@
<artifactId>jdbi3-core</artifactId>
<version>${jdbi3.version}</version>
</dependency>
<dependency>
<groupId>org.jeasy</groupId>
<artifactId>easy-rules-core</artifactId>
<version>${jeasy.version}</version>
</dependency>
<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>