) 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