"""Boolean scope evaluation for Configuration Policies. A policy's scopejson is a small typed boolean AST over two endpoint dimensions, PC type and installed application. This module is the ONLY place that interprets that AST: it validates the shape (known node types, bounded depth) and evaluates a validated tree against a resolved endpoint context. Node grammar (all dict nodes carry a "type"): {"type": "and", "nodes": [, ...]} {"type": "or", "nodes": [, ...]} {"type": "not", "node": } {"type": "pctype", "id": } {"type": "app", "id": } {"type": "true"} # matches everything (an unscoped/global policy) Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested and so a future GE-Enforce assembly engine can reuse the exact same evaluator on the exact same AST when it composes policies. """ # Bound the tree so a hand-crafted or imported AST cannot cause pathological # recursion. Ten levels is far past any policy a human would author. MAX_SCOPE_DEPTH = 10 _LOGICAL = {'and', 'or', 'not'} _LEAF = {'pctype', 'app', 'true'} class ScopeError(ValueError): """Raised when a scope AST is malformed. Carries a human-readable reason.""" def validate_scope(node, _depth=0): """Validate a scope AST, raising ScopeError on any problem. Returns None on success. Call this at write time (CRUD) so a bad tree never reaches the database; evaluate_scope then trusts the stored shape. """ if _depth > MAX_SCOPE_DEPTH: raise ScopeError(f'scope nested deeper than {MAX_SCOPE_DEPTH} levels') if not isinstance(node, dict): raise ScopeError('scope node must be an object') ntype = node.get('type') if ntype not in _LOGICAL and ntype not in _LEAF: raise ScopeError(f'unknown scope node type: {ntype!r}') if ntype in ('and', 'or'): nodes = node.get('nodes') if not isinstance(nodes, list) or not nodes: raise ScopeError(f'{ntype} requires a non-empty "nodes" list') for child in nodes: validate_scope(child, _depth + 1) elif ntype == 'not': child = node.get('node') if child is None: raise ScopeError('not requires a "node"') validate_scope(child, _depth + 1) elif ntype in ('pctype', 'app'): ident = node.get('id') if not isinstance(ident, int) or isinstance(ident, bool): raise ScopeError(f'{ntype} requires an integer "id"') # 'true' carries no payload. def evaluate_scope(node, context): """Evaluate a validated scope AST against an endpoint context. context is expected to expose: context['computertypeid'] -> int or None context['appids'] -> set/collection of installed appids Returns True when the endpoint falls in scope. An empty/None scope means the policy is unscoped and matches everything (same as {"type": "true"}), which keeps a freshly-created policy usable before a scope is drawn. """ if node is None: return True ntype = node['type'] if ntype == 'true': return True if ntype == 'and': return all(evaluate_scope(child, context) for child in node['nodes']) if ntype == 'or': return any(evaluate_scope(child, context) for child in node['nodes']) if ntype == 'not': return not evaluate_scope(node['node'], context) if ntype == 'pctype': return context.get('computertypeid') == node['id'] if ntype == 'app': return node['id'] in (context.get('appids') or ()) # validate_scope should have caught this; be strict rather than silently # matching an endpoint against an unknown node. raise ScopeError(f'unknown scope node type at evaluation: {ntype!r}')