The policy builder only offered "Matches everything" (the `true` scope node), which also targets servers. Authoring an estate-wide policy almost always means "every PC an operator sits at", and a reboot or script push against a server is a materially different event. Adds an `allpcs` scope node that matches every endpoint except those whose computer type is server-designated. Server designation reads the computer type's ICON slug, which is nominally a UI hint and is now load-bearing; it was chosen over matching the type NAME (a site can rename "Server") and over a new isserver column (needs a migration). It is isolated behind SERVER_ICON so promoting it to a real flag later is a one-place change. The `true` node is retained and still matches servers, so existing policies keep their exact meaning rather than silently re-scoping. It stays visible in the dropdown only when editing a policy that already uses it; new policies default to `allpcs`. Covered by tests/test_plugins/test_computers_policy_allpcs.py, verified against a planted canary so the server-exclusion assertions are known to bite.
120 lines
5.1 KiB
Python
120 lines
5.1 KiB
Python
"""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": [<node>, ...]}
|
|
{"type": "or", "nodes": [<node>, ...]}
|
|
{"type": "not", "node": <node>}
|
|
{"type": "pctype", "id": <computertypeid:int>}
|
|
{"type": "app", "id": <appid:int>}
|
|
{"type": "allpcs"} # every PC EXCEPT those whose type is designated a server
|
|
{"type": "true"} # matches everything, servers included
|
|
|
|
"allpcs" vs "true": authoring an estate-wide policy almost always means "every
|
|
PC an operator sits at", not "every computer we have a row for". Those differ by
|
|
the servers, where a reboot or a script push is a materially different event.
|
|
"true" is retained because a stored scope of None is equivalent to it and older
|
|
policies were written with it, but the builder offers "All PCs" instead.
|
|
|
|
Server designation is read from the computer type's ICON slug ('server'). That
|
|
field is nominally a UI hint, which is a real wart - it is load-bearing here. It
|
|
was chosen over matching the type NAME because a site can rename "Server" but a
|
|
renamed type keeps its icon, and over a new isserver column because that needs a
|
|
migration this did not warrant. If server designation ever becomes a first-class
|
|
flag, this is the single place that reads it.
|
|
|
|
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
|
|
|
|
# The computer-type icon slug that designates a type as a server. See the module
|
|
# docstring for why the icon carries this.
|
|
SERVER_ICON = 'server'
|
|
|
|
_LOGICAL = {'and', 'or', 'not'}
|
|
_LEAF = {'pctype', 'app', 'true', 'allpcs'}
|
|
|
|
|
|
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' and 'allpcs' carry 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
|
|
context['isserver'] -> bool, whether the type is server-designated
|
|
|
|
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 == 'allpcs':
|
|
# Absent designation means "not a server": an endpoint with no type set
|
|
# is an ordinary PC until something says otherwise.
|
|
return not context.get('isserver', False)
|
|
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}')
|