Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows

This commit is contained in:
2026-09-02 16:15:53 -04:00
parent 2144305433
commit bb733f1332
84 changed files with 7107 additions and 186 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-dark);
background: var(--bg);
border: 1px solid var(--warning);
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

@@ -559,9 +559,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,
@@ -633,7 +633,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