"""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:'. # 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:).""" 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: 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