Files
cproudlock e98e15259e Unpick one site's conventions from the shipped bundle, and bind the launch-path
check to the file it actually runs

Two clusters the five-agent review found. Both are about a product that another
plant has to be able to install.

ADR-015: the shipped monitor bundle was the originating plant's fleet
convention frozen into product seed.

  The business policy was seeded as "RPCA Shopfloor App Monitors". RPCA is that
  plant's facility designator, the row is source='shipped', and the API refused
  to rename it - so every adopting site would carry another plant's label at the
  top of its policy list forever. 0007 now seeds the neutral name, and 0010
  renames it on installs that already stamped 0007, guarded so it cannot collide
  with a policy a site made itself. Downgrade is one-way on purpose: putting
  another site's designator back is not a state worth reaching.

  Which PC type the bundle attaches to was the literal 'Shopfloor PC'. A plant
  whose type is "Shop Floor Workstation" silently got no shipped monitors at
  all, and one using that name for something else got all of them. It is a
  setting now, defaulting to the seeded name so nothing changes here, and a
  blank value attaches the bundle to nothing - the right neutral answer for a
  site that does not want it.

  Shipped rows refused every edit, including isactive and launchpath. A plant
  whose eDNC is on D:, or that runs no UDC, could not correct the path or switch
  the monitor off, and then carried permanent false drift for it. ADR-015 allows
  seed data BECAUSE a site can edit it afterwards. Paths and the active flag are
  now editable; kind and displayname stay fixed, because that pair is the
  identity 0007's bundle matches on.

  Also corrected two false claims in 0007's docstring: the policy tables belong
  to the computers plugin, not the core baseline, and a guarded no-op does NOT
  reconcile later - Alembic stamps the revision either way, so the bundle would
  simply never seed.

LaunchPathPolicy was purely lexical. GetFullPath is string normalization that
never touches the disk, so the root check constrained the NAME while File.Exists
and CreateProcess followed reparse points to somewhere else. A junction under a
writable directory in an approved root - creating one needs no privilege, only
write access to the parent, and vendor trees under Program Files are not
reliably locked down - turned WebUI monitor-edit rights into code execution as
the console operator. It now resolves the real target, leaf and directory chain
both, and re-checks THAT against the roots, rejecting if resolution fails.

Alternate data streams passed the whole policy on net8.0: an interior colon no
longer throws in GetFullPath, so "readme.txt:payload.exe" reported an .exe
extension, passed the root check, and existed. Rejected explicitly now.

Six tests added for the shipped-row and pctype behaviour. The Core project
compiles clean. The launch-path change has NOT been exercised against real
junctions, ADS or 8.3 names - that needs the win11 VM, and per the reviewer it
wants a planted-violation suite there rather than my say-so.

Still open from the review: monitor revocation does not take effect until agent
restart, ActionPicker is keyboard-inoperable, the lightbox has no focus trap,
one attached monitor grants generic any-target process control, and screenshots
are retained unencrypted in the user profile.

Scope: product
2026-09-02 15:44:17 -04:00

236 lines
10 KiB
Python

"""Endpoint monitor definitions API (the monitor catalog CRUD + resolver).
A monitor definition is the reusable "what" an operator builds on the Settings
page: a named process or service with a bespoke display label and optional
launch/collector paths. Attaching a monitor to a PC type is a site-policy
concern (see plugins/computers policies); this surface owns the definitions
themselves plus the resolve-by-pctype read the agent uses at runtime.
Shipped rows (source='shipped') are product-owned: readable by everyone with
the view permission but not editable or deletable through this API.
The resolve-by-pctype read returns the monitor set a deployed agent should watch
for its PC type: the shipped defaults for 'Shopfloor PC' unioned with whatever
active site policies attach to that type. Shipped monitors are hardcoded to
'Shopfloor PC' as a proof of concept until they are authored through the policy
route like any user-built monitor.
"""
from flask import request
from flask_jwt_extended import current_user
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
require_role, Setting,
)
from ..models import EndpointMonitor
from .routes import endpoints_bp
# Which PC type the shipped monitors attach to, as a SETTING rather than a
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
# type is called "Shop Floor Workstation" got no shipped monitors at all when
# this was hardcoded, and a plant that happens to use the name for something
# else got all of them. The default matches the seeded type so nothing changes
# for a site that uses it; a blank setting attaches the shipped bundle to
# nothing, which is the right neutral behaviour for a site that does not want
# it rather than a silent mismatch.
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
def shipped_pctype():
"""The PC type the shipped monitor bundle attaches to at this site."""
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
return (configured or '').strip()
_VALID_KINDS = ('process', 'service')
def _validate_monitor_payload(body, kind):
"""Return an error message for an invalid create/update body, or None."""
displayname = (body.get('displayname') or '').strip()
if not displayname:
return 'displayname is required'
if kind not in _VALID_KINDS:
return "kind must be 'process' or 'service'"
if kind == 'process' and not (body.get('processname') or '').strip():
return 'processname is required for a process monitor'
if kind == 'service' and not (body.get('servicename') or '').strip():
return 'servicename is required for a service monitor'
return None
@endpoints_bp.route('/monitors', methods=['GET'])
@require_permission('endpoints.view')
def list_monitors():
"""List monitor definitions (shipped and local)."""
monitors = EndpointMonitor.query.order_by(
EndpointMonitor.source.desc(),
EndpointMonitor.displayname.asc()).all()
return success_response([monitor.to_dict() for monitor in monitors])
@endpoints_bp.route('/monitors', methods=['POST'])
@require_role('admin')
def create_monitor():
"""Create a user-defined (local) monitor definition."""
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or '').strip().lower()
problem = _validate_monitor_payload(body, kind)
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
monitor = EndpointMonitor(
kind=kind,
displayname=(body.get('displayname') or '').strip(),
processname=(body.get('processname') or '').strip() or None,
servicename=(body.get('servicename') or '').strip() or None,
launchpath=(body.get('launchpath') or '').strip() or None,
datacollectorpath=(body.get('datacollectorpath') or '').strip() or None,
logcollectorpath=(body.get('logcollectorpath') or '').strip() or None,
source='local',
isactive=bool(body.get('isactive', True)),
)
db.session.add(monitor)
db.session.commit()
return success_response(monitor.to_dict(), message='Monitor created',
http_code=201)
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
@require_role('admin')
def update_monitor(monitor_id):
"""Update a local monitor definition. Shipped rows are read-only."""
monitor = db.session.get(EndpointMonitor, monitor_id)
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or monitor.kind or '').strip().lower()
problem = _validate_monitor_payload(
{
'displayname': body.get('displayname', monitor.displayname),
'processname': body.get('processname', monitor.processname),
'servicename': body.get('servicename', monitor.servicename),
},
kind,
)
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
# one site's disk layout. It used to refuse every edit, which meant a plant
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
# the launch path nor switch the monitor off - and then got permanent false
# drift warnings for it. ADR-015 allows seed data precisely because a site
# can edit it afterwards.
#
# So: kind and displayname are the identity 0007's policy bundle matches on
# and stay fixed on a shipped row; the paths and the active flag, which are
# what actually differ between plants, are editable.
if monitor.source != 'local':
for field in ('kind', 'displayname'):
if field in body and (body.get(field) or '').strip() != (
getattr(monitor, field) or ''):
return error_response(
ErrorCodes.FORBIDDEN,
'A shipped monitor keeps its kind and display name; its '
'paths and active flag can be changed', http_code=403)
else:
monitor.kind = kind
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
monitor.datacollectorpath = (body.get('datacollectorpath', monitor.datacollectorpath) or '').strip() or None
monitor.logcollectorpath = (body.get('logcollectorpath', monitor.logcollectorpath) or '').strip() or None
if 'isactive' in body:
monitor.isactive = bool(body.get('isactive'))
db.session.commit()
return success_response(monitor.to_dict(), message='Monitor updated')
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
@require_role('admin')
def delete_monitor(monitor_id):
"""Delete a local monitor definition. Shipped rows are protected."""
monitor = db.session.get(EndpointMonitor, monitor_id)
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
if monitor.source != 'local':
return error_response(ErrorCodes.FORBIDDEN,
'Shipped monitors cannot be deleted', http_code=403)
db.session.delete(monitor)
db.session.commit()
return success_response(None, message='Monitor deleted')
def _resolve_pctype_monitors(pctype):
"""Monitor definitions a PC type should watch: shipped defaults + policy.
Shipped monitors attach to the PC type named by the shipped-pctype setting;
policy-attached monitors come from the computers policy resolver by PC type.
Returns a list of EndpointMonitor rows (active only), de-duped by id.
"""
selected = {}
shipped_for = shipped_pctype()
if shipped_for and pctype == shipped_for:
for monitor in EndpointMonitor.query.filter_by(
source='shipped', isactive=True).all():
selected[monitor.endpointmonitorid] = monitor
computertypeid = _computertypeid_for(pctype)
if computertypeid is not None:
policy_ids = _policy_monitor_ids(computertypeid)
if policy_ids:
for monitor in EndpointMonitor.query.filter(
EndpointMonitor.endpointmonitorid.in_(policy_ids),
EndpointMonitor.isactive.is_(True)).all():
selected[monitor.endpointmonitorid] = monitor
return list(selected.values())
def _computertypeid_for(pctype):
"""Resolve a PC type name to its computertypeid, or None."""
try:
from plugins.computers.models import ComputerType
except Exception:
return None
row = ComputerType.query.filter_by(computertype=pctype).first()
return row.computertypeid if row else None
def _policy_monitor_ids(computertypeid):
"""Monitor ids attached to a PC type by effective policy, or empty set."""
try:
from plugins.computers.services.policyresolver import monitor_ids_for_pctype
except Exception:
return set()
return monitor_ids_for_pctype(computertypeid)
@endpoints_bp.route('/monitors/resolve', methods=['GET'])
@require_permission('endpoints.view')
def resolve_monitors():
"""Resolve the monitor set for a PC type (agent runtime fetch).
Query param pctype names the computer type. Returns the definitions the
agent should watch: shipped defaults for 'Shopfloor PC' plus any monitors
active site policies attach to that type.
"""
pctype = (request.args.get('pctype') or '').strip()
if not pctype:
return error_response(ErrorCodes.VALIDATION_ERROR,
'pctype is required', http_code=400)
monitors = _resolve_pctype_monitors(pctype)
return success_response({
'pctype': pctype,
'monitors': [monitor.to_dict() for monitor in monitors],
})