396 lines
18 KiB
Python
396 lines
18 KiB
Python
"""Server-side allowlist of endpoint agent commands.
|
|
|
|
The WebUI never sends an arbitrary command string. It sends an allowlisted
|
|
ACTION NAME; the concrete on-endpoint behavior (which script to run, how to stop
|
|
a process) lives in the agent. This module is the single source of truth for
|
|
which action names are legal and what they mean, so both the enqueue route and
|
|
the settings/UI surface validate against the same set.
|
|
|
|
The structure anticipates per-PC-type allowlists: today every computer may run
|
|
every action in COMMAND_ALLOWLIST, but ALLOWED_ACTIONS_FOR is the seam a later
|
|
change swaps for a type-scoped lookup (inspection PCs vs shopfloor PCs).
|
|
"""
|
|
|
|
# action name -> operator-facing metadata. label/description feed the UI; the
|
|
# agent maps the action name to its local handler (see CommandExecutor).
|
|
#
|
|
# Two families of action:
|
|
# * Named MTConnect actions - fixed target, no parameters. The original,
|
|
# bay-specific controls kept for the inline services-panel toggle.
|
|
# * Generic RMM actions (service_* / process_*) - the target is supplied by
|
|
# the policy action's params ('target' = a Windows service name or a process
|
|
# image like 'DncMain.exe'). This is what populates the Configuration Policy
|
|
# builder with reusable controls that any scope (PC type or application) can
|
|
# attach. The 'targetkind' hint tells the builder which target field to show.
|
|
# NOTE: agent-side execution of the generic actions is not wired yet; the
|
|
# server allowlist and policy plumbing land first (the whole feature is
|
|
# flag-gated), so a generic action saved today is authored and gated but is
|
|
# a no-op on the endpoint until the CommandExecutor handlers ship.
|
|
COMMAND_ALLOWLIST = {
|
|
'service_start': {
|
|
'label': 'Start service',
|
|
'description': 'Start a Windows service by name on the endpoint.',
|
|
'targetkind': 'service',
|
|
},
|
|
'service_stop': {
|
|
'label': 'Stop service',
|
|
'description': 'Stop a running Windows service by name on the endpoint.',
|
|
'targetkind': 'service',
|
|
},
|
|
'service_status': {
|
|
'label': 'Check service',
|
|
'description': 'Report whether a named Windows service is running.',
|
|
'targetkind': 'service',
|
|
},
|
|
'process_start': {
|
|
'label': 'Start process',
|
|
'description': 'Launch a process by executable path on the endpoint.',
|
|
'targetkind': 'process',
|
|
},
|
|
'process_stop': {
|
|
'label': 'Stop process',
|
|
'description': 'Stop a running process by image name on the endpoint.',
|
|
'targetkind': 'process',
|
|
},
|
|
'process_status': {
|
|
'label': 'Check process',
|
|
'description': 'Report whether a named process is running.',
|
|
'targetkind': 'process',
|
|
},
|
|
'mtconnect_start': {
|
|
'label': 'Start MTConnect',
|
|
'description': 'Run the MTConnect UDC agent start script on the endpoint.',
|
|
},
|
|
'mtconnect_stop': {
|
|
'label': 'Stop MTConnect',
|
|
'description': 'Stop the running MTConnect UDC agent on the endpoint.',
|
|
},
|
|
'mtconnect_status': {
|
|
'label': 'Check MTConnect',
|
|
'description': 'Report whether the MTConnect UDC agent is running.',
|
|
},
|
|
'system_restart': {
|
|
'label': 'Restart computer',
|
|
'description': 'Restart the endpoint computer immediately.',
|
|
},
|
|
'collect_now': {
|
|
'label': 'Collect now',
|
|
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
|
},
|
|
'network_connections': {
|
|
'label': 'Show network connections',
|
|
'description': 'Return the full TCP connection table with owning processes. '
|
|
'Read-only diagnostic; the standard report carries only '
|
|
'listening ports because established connections churn.',
|
|
},
|
|
'scheduledtask_run': {
|
|
'label': 'Run scheduled task',
|
|
'description': 'Start an already-registered Windows scheduled task on '
|
|
'the endpoint by its task path (for example '
|
|
'\\ReportIP). The agent does not create, modify, or '
|
|
'define tasks - it only starts one that already exists, '
|
|
'and only when the path clears the agent-side approved '
|
|
'task allowlist. The task runs in whatever context its '
|
|
'own registered definition specifies, not the agent\'s.',
|
|
'targetkind': 'scheduledtask',
|
|
},
|
|
'run_script': {
|
|
'label': 'Run script',
|
|
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
|
|
'fetches the script body by digest using its collector '
|
|
'credential and runs it in the script\'s configured '
|
|
'context (system or the logged-on user). CAUTION: a '
|
|
'script sent this way runs as SYSTEM unless its '
|
|
'descriptor pins the user context, so this is arbitrary '
|
|
'code execution on the endpoint by design. Grant '
|
|
'endpoints.deploy only to the most trusted operators, '
|
|
'and gate the UI behind an explicit confirm the way '
|
|
'system_restart is gated (see confirmRestart in '
|
|
'PCDetail.vue).',
|
|
'targetkind': 'script',
|
|
},
|
|
'update_app': {
|
|
'label': 'Install/update application',
|
|
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
|
'or by a specific version). The agent fetches the pinned '
|
|
'package by digest using its collector credential and runs '
|
|
'it silently.',
|
|
'targetkind': 'app',
|
|
},
|
|
}
|
|
|
|
|
|
# Dell Command | Update: TWO utilities, deliberately two separate actions.
|
|
# A scan reports what is available and changes nothing; an update installs and
|
|
# can reboot. Collapsing them into one parameterized action would make the
|
|
# destructive one reachable by editing a field, so they stay distinct and the
|
|
# UI can gate them differently.
|
|
#
|
|
# Both are driven through scheduledtask_run against ShopDB-owned task
|
|
# definitions (\ShopDB-DellCommandScan and \ShopDB-DellCommandUpdate, already in
|
|
# ScheduledTaskPolicy's shipped defaults) rather than a bespoke verb. The task
|
|
# definition - not the agent - pins the dcu-cli.exe path, its arguments, and its
|
|
# output path, which is why no new agent code is needed to run them.
|
|
#
|
|
# OUTPUT PATH CONVENTION. A ShopDB-initiated scan MUST write its report under
|
|
# %ProgramData%\ShopDB\EndpointAgent\ (the same SYSTEM-owned root as the agent
|
|
# logs), NOT to Dell's default location. Corporate-managed DCU tasks write to
|
|
# the default; keeping ShopDB's output separate is the only thing that makes
|
|
# "did ShopDB ask for this scan, or did corporate" answerable when the results
|
|
# are collected. The path lives in the task definition and is generated
|
|
# server-side, so it can change without touching the agent.
|
|
#
|
|
# STILL TO DO - the results collector. Firing DCU is the easy half. schtasks
|
|
# /run returns as soon as the task STARTS, so a successful scheduledtask_run
|
|
# means "scan started", never "scan finished" - the command result cannot carry
|
|
# findings. Reporting available updates back into ShopDB needs a collector that
|
|
# reads the report from the path above and folds it into the telemetry payload.
|
|
# That is a separate change from this one; do not infer from a green command
|
|
# result that the data path exists.
|
|
|
|
|
|
# Reserved seam - one-click mapped-drive remediation. NOT implemented; this is a
|
|
# placement note so the next person adds it here rather than inventing a new
|
|
# transport. When it lands it will re-establish a Disconnected mapping surfaced
|
|
# by the user-context card:
|
|
#
|
|
# 'remap_drive': {
|
|
# 'label': 'Reconnect mapped drive',
|
|
# 'description': 'Re-establish a disconnected network drive mapping.',
|
|
# 'targetkind': 'drive',
|
|
# },
|
|
#
|
|
# It MUST execute in the interactive USER session. A SYSTEM-context remap creates
|
|
# a mapping in SYSTEM's own drive table that the operator cannot see, so the card
|
|
# would keep reporting the drive as broken while the "fix" silently succeeded
|
|
# somewhere nobody is looking. An ELEVATED remap fails the same way, because UAC
|
|
# token separation gives the elevated context a different drive table - the same
|
|
# reason the probe itself runs unelevated from the tray.
|
|
#
|
|
# CommandExecutor.RunScriptInUserSession is the existing path for this.
|
|
|
|
|
|
# Actions that demand a permission stricter than the base endpoints.command.
|
|
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
|
|
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
|
|
# NOTE FOR THE ROLES/PERMISSIONS WORK: scheduledtask_run is deliberately mapped
|
|
# onto the EXISTING endpoints.deploy grant rather than introducing a new one, so
|
|
# the wire is pre-built and needs no migration. Be aware this WIDENS what
|
|
# endpoints.deploy means: it previously covered run_script + update_app, and now
|
|
# also covers starting approved scheduled tasks. If that bundle is too coarse,
|
|
# the clean split is a separate endpoints.corptools permission mapped here - no
|
|
# other code needs to change, because the enqueue route reads this table.
|
|
#
|
|
# Suggested operator-facing wording for the combined grant:
|
|
# "Caution: Operator can run scheduled tasks, update applications, and affect
|
|
# system-level changes to the endpoint."
|
|
# That wording is accurate for scheduledtask_run and update_app. It UNDERSTATES
|
|
# run_script, which is arbitrary SYSTEM code execution - if run_script stays in
|
|
# this bundle, say so plainly in the role description.
|
|
ELEVATED_ACTION_PERMISSIONS = {
|
|
'system_restart': 'endpoints.restart',
|
|
'scheduledtask_run': 'endpoints.deploy',
|
|
'run_script': 'endpoints.deploy',
|
|
'update_app': 'endpoints.deploy',
|
|
}
|
|
|
|
|
|
def elevated_permission_for(action):
|
|
"""The extra permission an action requires, or None for routine actions."""
|
|
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
|
|
|
|
|
# Maps a monitored services/processes row to the start/stop/status actions that
|
|
# control it, so the services panel can render an inline toggle on exactly the
|
|
# rows that have a registered command action. The key is the agent-reported
|
|
# 'app' label; 'kind' pins the toggle to the authoritative row (for MTConnect
|
|
# the agent.exe PROCESS is the source of truth - run.bat launches it and the
|
|
# Windows service state is not reliable), so the service row stays badge-only.
|
|
ROW_COMMAND_TARGETS = {
|
|
('MTConnect', 'process'): {
|
|
'start': 'mtconnect_start',
|
|
'stop': 'mtconnect_stop',
|
|
'status': 'mtconnect_status',
|
|
},
|
|
}
|
|
|
|
|
|
def row_actions_for(app, kind, name=None):
|
|
"""Return the {start, stop, status, target, targetkind} map for a row.
|
|
|
|
Two paths:
|
|
* A row registered in ROW_COMMAND_TARGETS (MTConnect) keeps its fixed,
|
|
named actions with no wire target - agent.exe is the source of truth and
|
|
the behavior is pinned in the agent.
|
|
* Any other process/service row maps to the generic primitives
|
|
(process_* / service_*) with the row's OWN reported name as the wire
|
|
target. This is what lets a new app light up start/stop/status without
|
|
an agent rebuild: the target rides the wire.
|
|
|
|
Rows with no usable kind return None and render as a plain status badge.
|
|
"""
|
|
if not app and not name:
|
|
return None
|
|
fixed = ROW_COMMAND_TARGETS.get((app, (kind or '').lower()))
|
|
if fixed:
|
|
return {**fixed, 'target': None, 'targetkind': None}
|
|
normalized = (kind or '').lower()
|
|
if normalized == 'process' and name:
|
|
return {
|
|
'start': 'process_start',
|
|
'stop': 'process_stop',
|
|
'status': 'process_status',
|
|
'target': name,
|
|
'targetkind': 'process',
|
|
}
|
|
if normalized == 'service' and name:
|
|
return {
|
|
'start': 'service_start',
|
|
'stop': 'service_stop',
|
|
'status': 'service_status',
|
|
'target': name,
|
|
'targetkind': 'service',
|
|
}
|
|
return None
|
|
|
|
|
|
def is_allowed_action(action):
|
|
"""True when action is a known, allowlisted command name."""
|
|
return action in COMMAND_ALLOWLIST
|
|
|
|
|
|
def action_targetkind(action):
|
|
"""The targetkind an action requires ('service'/'process'), or None.
|
|
|
|
Generic actions declare a targetkind in COMMAND_ALLOWLIST; fixed-target
|
|
actions (mtconnect_*) declare none and must not carry a wire target.
|
|
"""
|
|
meta = COMMAND_ALLOWLIST.get(action) or {}
|
|
return meta.get('targetkind')
|
|
|
|
|
|
def requires_target(action):
|
|
"""True when the action is generic and needs a wire target."""
|
|
return action_targetkind(action) is not None
|
|
|
|
|
|
# Monitor attachment as a policy action. A monitor definition is bound to a PC
|
|
# type by carrying a ConfigurationPolicyAction whose name is 'monitor:<id>'.
|
|
# Distinct names let one policy attach several monitors and fold naturally
|
|
# (first-match-wins per name), and they are validated in the policy layer but
|
|
# never enqueued as commands because ALLOWED_ACTIONS_FOR intersects with
|
|
# COMMAND_ALLOWLIST, which carries no monitor: entries.
|
|
MONITOR_ACTION_PREFIX = 'monitor:'
|
|
|
|
|
|
def monitor_action_name(endpointmonitorid):
|
|
"""Build the policy action name that attaches a monitor definition."""
|
|
return f'{MONITOR_ACTION_PREFIX}{endpointmonitorid}'
|
|
|
|
|
|
def is_monitor_action(action):
|
|
"""True when action names a monitor attachment (monitor:<id>)."""
|
|
return isinstance(action, str) and action.startswith(MONITOR_ACTION_PREFIX)
|
|
|
|
|
|
def monitor_id_from_action(action):
|
|
"""The monitor id an action attaches, or None when it is not a monitor action."""
|
|
if not is_monitor_action(action):
|
|
return None
|
|
raw = action[len(MONITOR_ACTION_PREFIX):]
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# Actions gated by ROLE ONLY, never by per-endpoint Configuration Policy.
|
|
#
|
|
# These are the Corporate Tools actions. They start already-registered,
|
|
# agent-allowlisted tasks and are not a per-machine configuration concern, so a
|
|
# policy that never mentions them must not remove them. Policy exists to shape
|
|
# what a given PC TYPE exposes (which monitors, which service primitives); it is
|
|
# not the authority on whether an admin may run a corporate tool.
|
|
#
|
|
# Without this exemption the intersection at the end of ALLOWED_ACTIONS_FOR
|
|
# silently drops these actions on any endpoint whose effective policy is
|
|
# non-empty, producing "Action not permitted by policy for this endpoint" even
|
|
# for a caller holding endpoints.deploy. The real gate for these stays
|
|
# ELEVATED_ACTION_PERMISSIONS (role), enforced in the enqueue route.
|
|
POLICY_EXEMPT_ACTIONS = frozenset({
|
|
'scheduledtask_run',
|
|
})
|
|
|
|
|
|
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
|
"""The action names an endpoint may run.
|
|
|
|
Base allowlist is flat (every action for every computer). When the computers
|
|
Configuration/Business Policy feature is enabled AND an assetid is given, the
|
|
result is narrowed to the actions the endpoint's effective policy permits -
|
|
this is the allowlist-gating half of the policy design. With the feature off
|
|
(or no assetid), it returns the full base allowlist, so nothing changes until
|
|
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away;
|
|
they are gated by role alone. Policy resolution is imported lazily to keep
|
|
this module free of a hard dependency on the computers plugin.
|
|
"""
|
|
base = set(COMMAND_ALLOWLIST.keys())
|
|
if assetid is None:
|
|
return base
|
|
try:
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
except Exception:
|
|
return base
|
|
effective = effective_policy_for(assetid)
|
|
allowed = effective.allowed_actions()
|
|
if not allowed:
|
|
# No policy spoke for this endpoint. With the feature off the resolver
|
|
# returns empty and we fall back to the base allowlist (unchanged
|
|
# behavior). This is intentionally permissive; deny-by-default would be
|
|
# a separate, explicit setting.
|
|
return base
|
|
# Attaching a monitor expresses intent to watch a process/service AND to
|
|
# control it: the panel renders that row's Start/Stop/Status buttons, so the
|
|
# gate must permit the matching generic primitives or every click 409s
|
|
# "not permitted by policy". Expand each monitor:<id> to its kind's control
|
|
# actions before intersecting.
|
|
allowed = allowed | _monitor_control_actions(allowed)
|
|
# Role-gated actions are added back unconditionally: policy narrows the
|
|
# per-endpoint surface, but it does not own the corporate tools.
|
|
allowed = allowed | POLICY_EXEMPT_ACTIONS
|
|
# Never widen past the code-level allowlist: policy can only gate within it.
|
|
return base & allowed
|
|
|
|
|
|
def _monitor_control_actions(action_names):
|
|
"""Control primitives granted by the monitor: attachments in a policy.
|
|
|
|
A process monitor grants process_start/stop/status; a service monitor grants
|
|
service_start/stop/status. The monitor's kind is read from the catalog, so a
|
|
definition's kind is the single source of truth for what its row can do.
|
|
"""
|
|
ids = set()
|
|
for name in action_names:
|
|
if isinstance(name, str) and name.startswith(MONITOR_ACTION_PREFIX):
|
|
monitor_id = monitor_id_from_action(name)
|
|
if monitor_id is not None:
|
|
ids.add(monitor_id)
|
|
if not ids:
|
|
return set()
|
|
|
|
try:
|
|
from .models import EndpointMonitor
|
|
except Exception:
|
|
return set()
|
|
|
|
granted = set()
|
|
for monitor in EndpointMonitor.query.filter(
|
|
EndpointMonitor.endpointmonitorid.in_(ids)).all():
|
|
kind = (monitor.kind or '').lower()
|
|
if kind == 'process':
|
|
granted |= {'process_start', 'process_stop', 'process_status'}
|
|
elif kind == 'service':
|
|
granted |= {'service_start', 'service_stop', 'service_status'}
|
|
return granted
|
|
|