Make alertscope actually target fleet alerts

alertscope was stored, serialised and editable in the authoring form but
never read when alerts were handed to an endpoint, so every fleet alert
went to every machine regardless of what was entered. The control implied
a capability nothing enforced.

get_fleet_alerts now resolves the polling endpoint's facts and filters
each alert through its scope. Supported keys: hostname, assettype,
computertype, location, businessunit. A key may hold one value or a list
(any match), matching is case-insensitive since these are operator-typed
names, and multiple keys narrow together.

Unrecognised keys are ignored rather than treated as match-nothing. A
typo like {"hostnam": "BAY-01"} would otherwise mute the alert fleet-wide
and look identical to a delivery failure; sending wider than intended is
the safe direction for something whose job is to warn people. A garbled
non-dict scope falls back to the whole fleet for the same reason. The
real fix for typos belongs at authoring time, not delivery.

BusinessUnit is not exported from shopdb.api, so its name is resolved by
table rather than importing the model and breaking the plugin import
contract.

Ten tests cover the narrowing cases and the fail-open behaviour.
This commit is contained in:
benjamin.baker
2026-09-09 15:40:40 -04:00
parent e0a7a51d46
commit 494b837bae
2 changed files with 236 additions and 0 deletions

View File

@@ -3175,6 +3175,93 @@ def serve_operator_report_image(operatorreportid: int):
_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8'
# Scope keys an alert may be narrowed by.
# rather than treated as a match-nothing, so a typo cannot silently mute an
# alert - see _alert_targets_endpoint.
_ALERT_SCOPE_KEYS = ('hostname', 'assettype', 'computertype', 'location',
'businessunit')
def _endpoint_scope_facts(hostname):
"""The scope-able attributes of the endpoint asking for its alerts.
Returns a dict of lowercased strings keyed by _ALERT_SCOPE_KEYS, or None if
the hostname resolves to no computer. Values that are unset on the asset are
absent from the dict, which makes them unmatchable rather than empty-string
matchable.
"""
from plugins.computers.models import Computer, ComputerType
from shopdb.api import Location, AssetType
row = (db.session.query(Computer.hostname, AssetType.assettype,
ComputerType.computertype, Location.locationname,
Asset.businessunitid)
.join(Asset, Asset.assetid == Computer.assetid)
.outerjoin(ComputerType,
ComputerType.computertypeid == Computer.computertypeid)
.outerjoin(AssetType, AssetType.assettypeid == Asset.assettypeid)
.outerjoin(Location, Location.locationid == Asset.locationid)
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
if row is None:
return None
hostname_value, assettype, computertype, locationname, businessunitid = row
facts = {'hostname': hostname_value, 'assettype': assettype,
'computertype': computertype, 'location': locationname}
# BusinessUnit is not on the plugin contract surface (shopdb.api), so resolve
# its name by table rather than importing the model and breaking the import
# contract the way the mailer import did.
if businessunitid is not None:
name = db.session.execute(
db.text('SELECT businessunit FROM businessunits '
'WHERE businessunitid = :bid'),
{'bid': businessunitid}).scalar()
facts['businessunit'] = name
return {k: str(v).strip().lower() for k, v in facts.items()
if v is not None and str(v).strip()}
def _alert_targets_endpoint(alertscope, facts):
"""Does an alert's scope include this endpoint?
An absent, empty or unusable scope means the whole fleet - that is the
default and the historical behaviour. A scope with keys narrows the alert:
every recognised key must match, and a key may hold a single value or a list
of values, any of which matches. Matching is case-insensitive because these
are operator-typed names, not identifiers.
Unrecognised keys are IGNORED rather than failing the match. A scope is
authored by hand and a typo like {"hostnam": "BAY-01"} would otherwise mute
the alert everywhere and look like a delivery failure. Ignoring the key
sends the alert wider than intended, which is the safe direction for
something whose purpose is to warn people.
"""
if not isinstance(alertscope, dict) or not alertscope:
return True
for key in _ALERT_SCOPE_KEYS:
if key not in alertscope:
continue
wanted = alertscope[key]
if wanted is None:
continue
if isinstance(wanted, (list, tuple, set)):
candidates = {str(v).strip().lower() for v in wanted if v is not None}
else:
candidates = {str(wanted).strip().lower()}
candidates.discard('')
if not candidates:
continue
if facts.get(key) not in candidates:
return False
return True
@endpoints_bp.route('/alerts', methods=['GET'])
def get_fleet_alerts():
"""Fleet alerts for one endpoint by hostname (collector-token auth).
@@ -3228,8 +3315,16 @@ def get_fleet_alerts():
results = query.all()
# Resolved once per poll. An unknown hostname cannot happen here - the
# asset lookup above already returned - but guard anyway so a missing
# computer row means fleet-wide rather than a crash.
facts = _endpoint_scope_facts(hostname) or {}
alerts = []
for notification, ntype in results:
if not _alert_targets_endpoint(notification.alertscope, facts):
continue
title = notification.notification[:100] if notification.notification else ''
if '\n' in title:
title = title.split('\n')[0][:100] or '(no title)'