"""Logic for handling per-endpoint rules.""" import logging import re import yaml from ddtrace import tracer from grass import config from grass.logic import user logger = logging.getLogger(config.LOGGER_NAME) logger.setLevel(config.LOGGER_LEVEL) class EndpointRuleType: """Endpoint rule type constants. FLASK ----- Provides flask-like format for endpoint rules. You can use the following dynamic route args that will be converted to the corresponding regex operators: - match any numeric value - match any string <*> - match any number of any characters Examples: '/holds///average' '/account/<*>' Please, not use arg names like you'd do in Flask ()! REGEX ----- Plain regex rule type. Example: '^\\/holds\\/\\w+\\/\\d+$' """ FLASK = 'flask' REGEX = 'regex' class EndpointRule: """Class that encapsulates an endpoint rule.""" def __init__( self, path: str, methods: list = ['*'], group_roles: dict = {}, rule_type: str = EndpointRuleType.FLASK, ): """Create a new EndpointRule. Args: path (str): regex expression, defining the path methods (list): a list of allowed HTTP methods group_roles (dict): dict with group as a key, roles list as a value """ self.path = path if rule_type == EndpointRuleType.FLASK: path = route_to_regex(path) self.path_regex = re.compile(path) self.methods = set(methods) self.group_roles = group_roles self.rule_type = rule_type @tracer.wrap() def match(self, path: str, method: str) -> bool: """Check if provided path and method match this rule. Args: path (str): endpoint path to check method (str): HTTP method to check Returns: bool: matched or not """ if not self.path_regex.match(path): return False return '*' in self.methods or method in self.methods @tracer.wrap() def is_allowed(self, group: str, roles: [int]) -> bool: """Check access for specified role. Args: group (str): can be 'oa' or 'alw' roles (list[int]): roles to check Returns: bool: allows access or not """ allowed_roles = set(self.fetch_allowed_resources(group)) return '*' in allowed_roles or bool(allowed_roles.intersection(set(roles))) @tracer.wrap() def fetch_allowed_resources(self, group: str): """Fetch resources allowed for specified group. Args: group (str): can be 'oa' or 'alw' Returns: list: list of resources if exists """ return self.group_roles.get(group, []) @tracer.wrap() def from_yaml(rule: dict) -> 'EndpointRule': """Create EndpointRule instance from parsed yaml. Args: rule (dict): a rule dictionary from yaml file Returns: EndpointRule: a new instance of the rule class. """ return EndpointRule( path=rule['path'], methods=rule['methods'], group_roles=rule['groups'], rule_type=rule.get('rule_type', EndpointRuleType.FLASK), ) @tracer.wrap() def route_to_regex(route: str) -> str: """Convert Flask-like route to a valid regex pattern. Args: route (str): route to convert Returns: str: regex pattern """ remap = { '': '\\d+', '': '[\\w_\\-]+', '<*>': '.*', } for token, rx in remap.items(): route = route.replace(token, rx) return f'^{route}$' class EndpointRulesValidator: """Validator for checking role, path and method with rules list.""" def __init__(self, yaml_file: str): """Constructor.""" self.rules = self._load_rules_yaml(yaml_file) @tracer.wrap() def _load_rules_yaml(self, yaml_file: str) -> [EndpointRule]: """Load rules list from yaml file. Args: yaml_file (str): yaml file name Returns: [EndpointRule]: a list of EndpointRule objects """ with open(yaml_file) as f: yaml_rules = yaml.load(f, Loader=yaml.FullLoader) rules = yaml_rules['rules'] return [from_yaml(r) for r in rules] @tracer.wrap() def _find_rules(self, path: str, method: str) -> [EndpointRule]: """Try to find a list of EndpointRules by endpoint path and method. Args: path (str): endpoint path to search for method (str): HTTP method to filter by (rules with methods=['*'] match any method) Returns: list[EndpointRule]: an list of endpoint rules """ path = path.rstrip('/') rules = [r for r in self.rules if r.match(path, method)] if not rules: logger.warning(f'NO RULES FOUND: path={method} {path}') return rules @tracer.wrap() def has_access(self, path: str, method: str, group: str, roles: [int]) -> bool: """Check access for the specified path, method and role. Args: path (str): endpoint path method (str): HTTP method group (str): user group (can be 'alw' or 'oa') roles (list[int]): user roles to check Returns: bool: has access or not """ rules = self._find_rules(path, method) if rules: return any([r.is_allowed(group, roles) for r in rules]) else: return False @tracer.wrap() def has_resource_access( self, path: str, method: str, group: str, user_id: str ) -> bool: """Check access for the specified path and method. Args: path (str): endpoint path method (str): HTTP method group (str): user group (can be 'alw' or 'oa') user_id (str): Returns: bool: has access or not """ rules = self._find_rules(path, method) if rules: for r in rules: resources = r.fetch_allowed_resources(group) if not resources: continue if '*' in resources: return True return user.is_allowed_for_any_resources(user_id, resources) return False