diff --git a/catalog-rest-service/pom.xml b/catalog-rest-service/pom.xml index 0d15720c4cf..b774a69e7ad 100644 --- a/catalog-rest-service/pom.xml +++ b/catalog-rest-service/pom.xml @@ -319,6 +319,10 @@ com.lmax disruptor + + org.jeasy + easy-rules-core + diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/AttributeBasedFacts.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/AttributeBasedFacts.java new file mode 100644 index 00000000000..cd01cd13ed8 --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/AttributeBasedFacts.java @@ -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 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 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 entityTags = Collections.emptyList(); + try { + entityTags = (List) 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; + } +} diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/CommonFields.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/CommonFields.java new file mode 100644 index 00000000000..d3fb0493e6e --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/CommonFields.java @@ -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; +} diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/PolicyEvaluator.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/PolicyEvaluator.java new file mode 100644 index 00000000000..df79f9b2886 --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/PolicyEvaluator.java @@ -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. + * + *

This class uses {@link DefaultRulesEngine} provided by j-easy/easy-rules package. + * + *

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} + * + *

The facts are constructed based on 3 inputs for the PolicyEvaluator: + * + *

- {@link MetadataOperation operation} to be performed + * + *

- {@link User} (subject) who performs the operation + * + *

- {@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 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(); + } +} diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/RuleCondition.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/RuleCondition.java new file mode 100644 index 00000000000..59800cd10b5 --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/RuleCondition.java @@ -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 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 userRoles = facts.get(CommonFields.USER_ROLES); + return rule.getUserRoleAttr() == null || userRoles.contains(rule.getUserRoleAttr()); + } +} diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/SetPermissionAction.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/SetPermissionAction.java new file mode 100644 index 00000000000..b92153dec97 --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/policyevaluator/SetPermissionAction.java @@ -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()); + } +} diff --git a/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/rule.json b/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/rule.json index 0ab4f93421e..5b4a7e9b100 100644 --- a/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/rule.json +++ b/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/rule.json @@ -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 } \ No newline at end of file diff --git a/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/tagBased.json b/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/tagBased.json deleted file mode 100644 index fd6900233cb..00000000000 --- a/catalog-rest-service/src/main/resources/json/schema/entity/policies/accessControl/tagBased.json +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/catalog-rest-service/src/main/resources/json/schema/type/tagLabel.json b/catalog-rest-service/src/main/resources/json/schema/type/tagLabel.json index 6d2a6464310..54d677be7d7 100644 --- a/catalog-rest-service/src/main/resources/json/schema/type/tagLabel.json +++ b/catalog-rest-service/src/main/resources/json/schema/type/tagLabel.json @@ -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.", diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/policyevaluator/PolicyEvaluatorTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/policyevaluator/PolicyEvaluatorTest.java new file mode 100644 index 00000000000..a2944dd8960 --- /dev/null +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/policyevaluator/PolicyEvaluatorTest.java @@ -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 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 teamNames) { + // TODO: Use role instead of team when user schema is extended to accommodate role. + List 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 tags = new ArrayList<>(); + tags.add(new TagLabel().withTagFQN(PII_SENSITIVE)); + return new Table().withName("random-table").withTags(tags); + } +} diff --git a/pom.xml b/pom.xml index d39b0f00e57..3ab092aa7bb 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ 2.15.0 0.8.6 3.24.1 + 4.1.0 1.5.0 2.11.0 8.0.4 @@ -141,6 +142,11 @@ jdbi3-core ${jdbi3.version} + + org.jeasy + easy-rules-core + ${jeasy.version} + commons-cli commons-cli