Add configuration policy monitors and action picker for computers

Configuration policies need a way to attach process/service monitors so drift can be evaluated against a machine's desired state. This wires the policy resolver and routes to the shared monitor catalog and adds an action picker UI that can create process monitors with an optional launch path.
This commit is contained in:
benjamin.baker
2026-09-01 15:45:52 -04:00
parent a24429046c
commit d973ce6c26
9 changed files with 492 additions and 36 deletions

View File

@@ -40,6 +40,23 @@ def _known_actions():
return set(COMMAND_ALLOWLIST.keys())
def _is_valid_action(name):
"""True when an action name is either an allowlisted command or a monitor
attachment (monitor:<id>) pointing at an existing monitor definition.
Monitor actions are intentionally kept out of the command allowlist so they
are never enqueued as commands; they are only valid as policy attachments.
"""
if name in _known_actions():
return True
from plugins.endpoints.commands import monitor_id_from_action
from plugins.endpoints.models import EndpointMonitor
monitorid = monitor_id_from_action(name)
if monitorid is None:
return False
return db.session.get(EndpointMonitor, monitorid) is not None
def _configpolicy_dict(policy):
data = policy.to_dict()
data['actions'] = [a.to_dict() for a in policy.actions]
@@ -89,11 +106,15 @@ def _apply_actions(policy, actions):
Returns an error_response on failure, else None.
"""
known = _known_actions()
policy.actions.clear()
# Flush the deletes before re-appending. The (configurationpolicyid, action)
# unique constraint collides if SQLAlchemy orders a re-added action's INSERT
# before the old row's DELETE in the same flush, which surfaces as a generic
# "value already in use" IntegrityError on an otherwise valid edit.
db.session.flush()
for row in actions or []:
name = (row.get('action') or '').strip()
if name not in known:
if not _is_valid_action(name):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action: {name!r}')
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
@@ -259,6 +280,10 @@ def get_business_policy(policy_id: int):
def _apply_items(policy, items):
"""Replace a business policy's configuration-policy items, validating each."""
policy.items.clear()
# Flush the deletes before re-appending so the (businesspolicyid,
# configurationpolicyid) unique constraint does not collide with a re-added
# item whose INSERT would otherwise race ahead of the old row's DELETE.
db.session.flush()
seen = set()
for order, row in enumerate(items or []):
cpid = row.get('configurationpolicyid')
@@ -375,14 +400,19 @@ def delete_business_policy(policy_id: int):
@computers_bp.route('/policies/actions', methods=['GET'])
@require_permission('computers.view')
def list_policy_actions():
"""The allowlisted action names + labels a policy may reference."""
"""The allowlisted action names + labels a policy may reference.
Returns command actions tagged kind='command'. Monitor attachments are not
listed here: the UI builds that branch from the endpoints monitor catalog
(endpointsApi.monitors) so newly created monitors appear without a reload.
"""
gate = _feature_gate()
if gate:
return gate
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return success_response([
{'action': name, 'label': meta.get('label'),
'description': meta.get('description')}
'description': meta.get('description'), 'kind': 'command'}
for name, meta in COMMAND_ALLOWLIST.items()
])

View File

@@ -70,8 +70,8 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/business-policies',
name: 'business-policies',
path: 'settings/site-policies',
name: 'site-policies',
component: () => import('./views/BusinessPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
}

View File

@@ -0,0 +1,265 @@
<template>
<div class="action-picker" @keydown.esc="closeDropdown">
<input
ref="inputEl"
type="text"
class="form-control"
:placeholder="placeholder"
v-model="query"
@focus="openDropdown"
@input="openDropdown"
/>
<div v-if="open" class="action-dropdown">
<!-- Command level: commands + a Monitor branch entry -->
<template v-if="level === 'command'">
<div
v-for="opt in filteredCommands"
:key="opt.action"
class="action-option"
@mousedown.prevent="selectCommand(opt)"
>
{{ opt.label || opt.action }}
</div>
<div
class="action-option action-branch"
@mousedown.prevent="enterMonitorLevel"
>
Monitor...
</div>
<div v-if="filteredCommands.length === 0" class="action-empty">No matching actions</div>
</template>
<!-- Monitor level: two create entries pinned on top, then existing monitors -->
<template v-else-if="level === 'monitor'">
<div class="action-option action-create" @mousedown.prevent="startCreate('process')">
&lt;Create Process Monitor&gt;
</div>
<div class="action-option action-create" @mousedown.prevent="startCreate('service')">
&lt;Create Service Monitor&gt;
</div>
<div class="action-divider"></div>
<div
v-for="m in filteredMonitors"
:key="m.endpointmonitorid"
class="action-option"
@mousedown.prevent="selectMonitor(m)"
>
{{ m.displayname }} <span class="monitor-kind">({{ m.kind }})</span>
</div>
<div v-if="filteredMonitors.length === 0" class="action-empty">No matching monitors</div>
</template>
<!-- Create level: inline text field to name the new process/service monitor -->
<template v-else>
<div class="action-create-form" @mousedown.prevent>
<label class="action-create-label">
{{ createkind === 'service' ? 'Service name to monitor' : 'Process name to monitor' }}
</label>
<input
ref="createInputEl"
type="text"
class="form-control"
:placeholder="createkind === 'service' ? 'e.g. Spooler' : 'e.g. agent.exe'"
v-model="createname"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
/>
<template v-if="createkind === 'process'">
<label class="action-create-label">
Launch path (optional)
</label>
<input
type="text"
class="form-control"
placeholder="e.g. C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
v-model="createlaunchpath"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
/>
</template>
<div class="action-create-actions">
<button type="button" class="btn btn-primary btn-sm" :disabled="!createname.trim() || creating" @mousedown.prevent="submitCreate">
Create
</button>
<button type="button" class="btn btn-secondary btn-sm" @mousedown.prevent="cancelCreate">
Cancel
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { endpointsApi } from '@/api'
import { apiError } from '@/utils/apiError'
import { useToast } from '@/composables/toast'
const props = defineProps({
modelValue: { type: String, default: '' },
commandActions: { type: Array, default: () => [] },
monitors: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'monitor-created'])
const toast = useToast()
const inputEl = ref(null)
const createInputEl = ref(null)
const open = ref(false)
// 'command' -> top level, 'monitor' -> monitor list, 'create' -> inline name form
const level = ref('command')
const query = ref('')
const createkind = ref('process')
const createname = ref('')
const createlaunchpath = ref('')
const creating = ref(false)
// Show the chosen action's friendly label in the input when not actively typing.
watch(() => props.modelValue, syncLabel, { immediate: true })
watch(() => props.monitors, () => { if (!open.value) syncLabel() })
function syncLabel() {
query.value = labelFor(props.modelValue)
}
function labelFor(action) {
if (!action) return ''
if (action.startsWith('monitor:')) {
const id = Number(action.slice('monitor:'.length))
const monitor = props.monitors.find(m => m.endpointmonitorid === id)
return monitor ? `Monitor: ${monitor.displayname} (${monitor.kind})` : action
}
const command = props.commandActions.find(c => c.action === action)
return command ? (command.label || command.action) : action
}
const placeholder = computed(() =>
level.value === 'monitor' ? 'Search monitors...' : 'Search actions...')
const filteredCommands = computed(() => {
const term = query.value.trim().toLowerCase()
if (!term) return props.commandActions
return props.commandActions.filter(c =>
(c.label || c.action).toLowerCase().includes(term))
})
const filteredMonitors = computed(() => {
const term = query.value.trim().toLowerCase()
if (!term) return props.monitors
return props.monitors.filter(m => m.displayname.toLowerCase().includes(term))
})
function openDropdown() {
open.value = true
}
function closeDropdown() {
open.value = false
level.value = 'command'
resetCreate()
syncLabel()
}
function enterMonitorLevel() {
level.value = 'monitor'
query.value = ''
inputEl.value?.focus()
}
function selectCommand(opt) {
emit('update:modelValue', opt.action)
open.value = false
level.value = 'command'
query.value = opt.label || opt.action
}
function selectMonitor(monitor) {
emit('update:modelValue', `monitor:${monitor.endpointmonitorid}`)
open.value = false
level.value = 'command'
query.value = `Monitor: ${monitor.displayname} (${monitor.kind})`
}
async function submitCreate() {
const displayname = createname.value.trim()
if (!displayname || creating.value) return
const kind = createkind.value
const payload = {
kind,
displayname,
processname: kind === 'process' ? displayname : '',
servicename: kind === 'service' ? displayname : '',
isactive: true
}
if (kind === 'process' && createlaunchpath.value.trim()) {
payload.launchpath = createlaunchpath.value.trim()
}
creating.value = true
try {
const response = await endpointsApi.monitors.create(payload)
const created = response.data.data
emit('monitor-created', created)
emit('update:modelValue', `monitor:${created.endpointmonitorid}`)
open.value = false
level.value = 'command'
resetCreate()
query.value = `Monitor: ${created.displayname} (${created.kind})`
} catch (err) {
toast.error(apiError(err, 'Failed to create monitor'))
} finally {
creating.value = false
}
}
function startCreate(kind) {
createkind.value = kind
createname.value = ''
createlaunchpath.value = ''
level.value = 'create'
nextTick(() => createInputEl.value?.focus())
}
function resetCreate() {
createname.value = ''
createlaunchpath.value = ''
creating.value = false
}
function cancelCreate() {
resetCreate()
level.value = 'monitor'
nextTick(() => inputEl.value?.focus())
}
</script>
<style scoped>
.action-picker { position: relative; flex: 1; min-width: 220px; }
.action-dropdown {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: 2px;
max-height: 320px;
overflow-y: auto;
background: var(--bg-card-solid);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
.action-option:hover { background: var(--bg); }
.action-branch { font-style: italic; }
.action-create { font-weight: 600; color: var(--primary); }
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }
.action-empty { padding: 6px 10px; color: var(--text-light); }
.monitor-kind { color: var(--text-light); }
.action-create-form { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.action-create-label { font-size: 12px; color: var(--text-light); }
.action-create-actions { display: flex; gap: 8px; }
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
<h2>Site Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
</div>
<div class="card">
@@ -42,7 +42,7 @@
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
<td colspan="6" style="text-align:center; color: var(--text-light);">No site policies</td>
</tr>
</tbody>
</table>
@@ -53,7 +53,7 @@
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Policy' : 'Add Business Policy' }}</h3>
<h3>{{ editing ? 'Edit Site Policy' : 'Add Site Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
@@ -196,14 +196,14 @@ async function save() {
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
error.value = apiError(err, 'Failed to save site policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
if (!confirm(`Delete site policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()

View File

@@ -25,7 +25,7 @@
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
{{ a.action }} ({{ a.mode }})
{{ actionLabel(a.action) }} ({{ a.mode }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
@@ -74,17 +74,23 @@
<div class="form-group">
<label>Actions</label>
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
<select v-model="a.action" class="form-control">
<option :value="''" disabled>Select action...</option>
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
{{ opt.label || opt.action }}
</option>
</select>
<ActionPicker
v-model="a.action"
:command-actions="actionCatalog"
:monitors="monitors"
@monitor-created="onMonitorCreated"
/>
<select v-model="a.mode" class="form-control mode-select">
<option value="gate">Allow (gate)</option>
<option value="desired">Desired state</option>
</select>
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
<p v-if="showDesiredWarning(a)" class="desired-warning">
Desired state keeps this app alive: on agent check-in the
server will try to START the process if a launch path is
mapped. Use gate to only report status with Start/Stop in the
Monitor UI.
</p>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
</div>
@@ -103,10 +109,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import ScopeNodeEditor from './ScopeNodeEditor.vue'
import ActionPicker from './ActionPicker.vue'
const toast = useToast()
@@ -114,6 +121,7 @@ const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const monitors = ref([])
const loading = ref(true)
const showModal = ref(false)
@@ -132,21 +140,60 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
// allSettled, not all: one failing endpoint must not blank the others.
// A rejected Promise.all left PC types and apps empty when only monitors
// 500'd, which looked like data loss.
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
computersApi.policies.actions(),
endpointsApi.monitors.list()
])
policies.value = pol.data.data || []
pctypes.value = types.data.data || []
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
// axios response, so read it directly. Reading .data on the array throws and
// rejects the whole Promise.all, blanking every dropdown.
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
actionCatalog.value = actions.data.data || []
} catch (err) {
console.error('Error loading configuration policies:', err)
const failures = []
if (pol.status === 'fulfilled') {
policies.value = pol.value.data.data || []
} else {
failures.push('policies')
console.error('Error loading policies:', pol.reason)
}
if (types.status === 'fulfilled') {
pctypes.value = types.value.data.data || []
} else {
failures.push('PC types')
console.error('Error loading PC types:', types.reason)
}
if (appList.status === 'fulfilled') {
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not
// an axios response, so read it directly.
const value = appList.value
apps.value = Array.isArray(value) ? value : (value.data?.data || value.data || [])
} else {
failures.push('applications')
console.error('Error loading applications:', appList.reason)
}
if (actions.status === 'fulfilled') {
actionCatalog.value = actions.value.data.data || []
} else {
failures.push('actions')
console.error('Error loading actions:', actions.reason)
}
if (monitorList.status === 'fulfilled') {
monitors.value = monitorList.value.data.data || []
} else {
failures.push('monitors')
console.error('Error loading monitors:', monitorList.reason)
}
if (failures.length) {
toast.error(`Failed to load: ${failures.join(', ')}. Some options may be unavailable.`)
}
} finally {
loading.value = false
}
@@ -177,6 +224,30 @@ function addAction() {
form.value.actions.push({ action: '', mode: 'gate' })
}
function onMonitorCreated(monitor) {
// Add the new definition to the local catalog so its label resolves and it
// is searchable on other action rows without a full reload.
if (monitor && !monitors.value.some(m => m.endpointmonitorid === monitor.endpointmonitorid)) {
monitors.value.push(monitor)
}
}
function actionLabel(action) {
if (!action) return action
if (action.startsWith('monitor:')) {
const id = Number(action.slice('monitor:'.length))
const monitor = monitors.value.find(m => m.endpointmonitorid === id)
return monitor ? `Monitor: ${monitor.displayname}` : action
}
const command = actionCatalog.value.find(c => c.action === action)
return command ? (command.label || command.action) : action
}
function showDesiredWarning(action) {
return !!action && action.mode === 'desired'
&& !!action.action && action.action.startsWith('monitor:')
}
async function save() {
saving.value = true
error.value = ''
@@ -213,11 +284,35 @@ async function remove(p) {
</script>
<style scoped>
.modal-lg {
max-width: 760px;
}
.modal-lg .modal-body {
max-height: 70vh;
overflow-y: auto;
}
.modal-lg :deep(.form-group) {
margin-bottom: 1.1rem;
}
.modal-lg :deep(.form-control) {
font-size: 14px;
padding: 0.55rem 0.7rem;
}
.scope-builder {
padding: 10px;
border: 1px solid var(--border-color, #444);
border: 1px solid var(--border);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.action-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 6px; }
.mode-select { max-width: 160px; }
.desired-warning {
flex-basis: 100%;
margin: 2px 0 0;
padding: 6px 8px;
font-size: 0.85em;
color: var(--warning-text, #8a6d3b);
background: var(--warning-bg, rgba(240, 173, 78, 0.12));
border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4));
border-radius: 4px;
}
</style>

View File

@@ -13,6 +13,7 @@ from .policy import (
BusinessPolicy,
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
)
@@ -28,6 +29,7 @@ __all__ = [
'BusinessPolicy',
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'POLICY_SOURCE_SHIPPED',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
]

View File

@@ -27,6 +27,12 @@ from .jsontext import JSONText
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
# treatment as any non-local source: the CRUD API refuses to edit or delete
# them, the seeder reconciles them. Distinct from 'local' so a site's own
# policies are never confused with what the product shipped.
POLICY_SOURCE_SHIPPED = 'shipped'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.

View File

@@ -429,9 +429,9 @@ class ComputersPlugin(BasePlugin):
},
{
'group': 'Computers',
'to': '/settings/business-policies',
'to': '/settings/site-policies',
'icon': 'layers',
'title': 'Business policies',
'title': 'Site policies',
'description': 'Ordered collections of configuration policies '
'assigned by priority',
'position': 28,
@@ -483,7 +483,7 @@ class ComputersPlugin(BasePlugin):
'valuetype': 'boolean',
'category': 'computers',
'description': 'Master switch for RMM-style Configuration and '
'Business Policies. Off by default: models exist '
'Site Policies. Off by default: models exist '
'but the CRUD routes, WebUI, and endpoint '
'enforcement stay inert so the whole feature can '
'be rolled back without a schema change.',

View File

@@ -166,3 +166,61 @@ def effective_policy_for(assetid):
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)
def _monitor_action_ids(effective):
"""Extract attached monitor ids from an EffectivePolicy's action names.
Monitor attachment rides the policy layer as an action named
'monitor:<endpointmonitorid>' (see plugins/endpoints/commands.py). This
reads those ids back out of a resolved policy so a caller can turn them into
concrete monitor definitions.
"""
ids = set()
for name in effective.allowed_actions():
if isinstance(name, str) and name.startswith('monitor:'):
raw = name[len('monitor:'):]
try:
ids.add(int(raw))
except (TypeError, ValueError):
continue
return ids
def monitor_ids_for_asset(assetid):
"""Set of endpointmonitorids attached to an endpoint by effective policy."""
return _monitor_action_ids(effective_policy_for(assetid))
def monitor_ids_for_pctype(computertypeid):
"""Set of endpointmonitorids attached to a PC type by effective policy.
Folds active Business Policies against a synthetic context carrying only the
PC type (no installed apps), which is what the agent runtime-fetch path needs
when it resolves its monitor set by type rather than by a specific asset.
Returns an empty set when the policy feature is off.
"""
from ..policyflag import policy_flag_enabled
if not policy_flag_enabled():
return set()
from ..models import BusinessPolicy
context = {'computertypeid': computertypeid, 'appids': set()}
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
resolved = {}
for businesspolicyid, config_policy in _iter_matching_configpolicies(
business_policies, context):
for action_row in config_policy.actions:
if not action_row.isactive:
continue
resolved.setdefault(action_row.action, True)
ids = set()
for name in resolved:
if isinstance(name, str) and name.startswith('monitor:'):
raw = name[len('monitor:'):]
try:
ids.add(int(raw))
except (TypeError, ValueError):
continue
return ids