harden CSV import reconciliation and preserve saved model selections

Default fuzzy reconcile threshold to 0 and refuse merges when digit runs differ so numbered labels like Bay 311 never bind to Bay 310. Keep edit forms from clearing saved model fields when category filtering hides untyped rows. Includes in-progress computers policy and endpoints command feature work.
This commit is contained in:
benjamin.baker
2026-08-28 09:29:09 -04:00
parent d68c6db9ab
commit 01734e5449
46 changed files with 4241 additions and 52 deletions

View File

@@ -1,5 +1,6 @@
"""Computers plugin API."""
from .routes import computers_bp
from . import policy_routes # noqa: F401 registers policy routes on computers_bp
__all__ = ['computers_bp']

View File

@@ -0,0 +1,406 @@
"""CRUD API for Configuration and Business Policies.
Registered on computers_bp under /api/computers/policies/... Kept in its own
module so the whole policy surface can be removed by not importing it - part of
the exit gate. Every route first checks the computers_configpolicies_enabled
switch and returns 404 while it is off, so enabling/disabling the feature never
half-exposes it.
Write-time validation lives here (not in the model): scope ASTs go through
validate_scope, and action names are checked against the endpoints command
allowlist, so a bad tree or an unknown action never reaches the database.
"""
from flask import request
from flask_jwt_extended import jwt_required
from shopdb.api import (db, AuditLog, success_response, error_response,
ErrorCodes, require_permission)
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
def _feature_gate():
"""Return a 404 response when the policy feature is off, else None."""
if not policy_flag_enabled():
return error_response(ErrorCodes.NOT_FOUND,
'Policy feature is not enabled', http_code=404)
return None
def _known_actions():
"""The code-level allowlist of action names a policy may reference."""
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return set(COMMAND_ALLOWLIST.keys())
def _configpolicy_dict(policy):
data = policy.to_dict()
data['actions'] = [a.to_dict() for a in policy.actions]
return data
def _businesspolicy_dict(policy):
data = policy.to_dict()
data['items'] = [{
'businesspolicyitemid': it.businesspolicyitemid,
'configurationpolicyid': it.configurationpolicyid,
'sortorder': it.sortorder,
'name': it.configurationpolicy.name if it.configurationpolicy else None,
} for it in policy.items]
return data
# =============================================================================
# Configuration Policies
# =============================================================================
@computers_bp.route('/policies/configuration', methods=['GET'])
@jwt_required(optional=True)
def list_configuration_policies():
gate = _feature_gate()
if gate:
return gate
policies = ConfigurationPolicy.query.order_by(ConfigurationPolicy.name).all()
return success_response([_configpolicy_dict(p) for p in policies])
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['GET'])
@jwt_required(optional=True)
def get_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
return success_response(_configpolicy_dict(policy))
def _apply_actions(policy, actions):
"""Replace a policy's actions from request data, validating each.
Returns an error_response on failure, else None.
"""
known = _known_actions()
policy.actions.clear()
for row in actions or []:
name = (row.get('action') or '').strip()
if name not in known:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action: {name!r}')
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
if mode not in _MODES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action mode: {mode!r}')
policy.actions.append(ConfigurationPolicyAction(
action=name,
mode=mode,
desiredstate=row.get('desiredstate'),
params=row.get('params'),
intervalminutes=row.get('intervalminutes'),
sortorder=row.get('sortorder', 0) or 0,
))
return None
@computers_bp.route('/policies/configuration', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_configuration_policy():
gate = _feature_gate()
if gate:
return gate
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
if ConfigurationPolicy.query.filter_by(name=name).first():
return error_response(ErrorCodes.CONFLICT,
f"Configuration policy '{name}' already exists",
http_code=409)
scope = data.get('scopejson')
if scope is not None:
try:
validate_scope(scope)
except ScopeError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
policy = ConfigurationPolicy(
name=name,
description=data.get('description'),
scopejson=scope,
source=POLICY_SOURCE_LOCAL,
)
err = _apply_actions(policy, data.get('actions'))
if err:
return err
db.session.add(policy)
AuditLog.log('created', 'ConfigurationPolicy', entityname=name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
message='Configuration policy created',
http_code=201)
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
if ConfigurationPolicy.query.filter_by(name=newname).first():
return error_response(ErrorCodes.CONFLICT,
f"Configuration policy '{newname}' already exists",
http_code=409)
policy.name = newname
if 'description' in data:
policy.description = data['description']
if 'isactive' in data:
policy.isactive = data['isactive']
if 'scopejson' in data:
scope = data['scopejson']
if scope is not None:
try:
validate_scope(scope)
except ScopeError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
policy.scopejson = scope
if 'actions' in data:
err = _apply_actions(policy, data['actions'])
if err:
return err
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
message='Configuration policy updated')
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
inuse = BusinessPolicyItem.query.filter_by(
configurationpolicyid=policy_id).count()
if inuse:
return error_response(
ErrorCodes.CONFLICT,
f'Cannot delete: used by {inuse} business policy item(s)',
http_code=409)
db.session.delete(policy)
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(None, message='Configuration policy deleted')
# =============================================================================
# Business Policies
# =============================================================================
@computers_bp.route('/policies/business', methods=['GET'])
@jwt_required(optional=True)
def list_business_policies():
gate = _feature_gate()
if gate:
return gate
policies = (BusinessPolicy.query
.order_by(BusinessPolicy.priority, BusinessPolicy.businesspolicyid)
.all())
return success_response([_businesspolicy_dict(p) for p in policies])
@computers_bp.route('/policies/business/<int:policy_id>', methods=['GET'])
@jwt_required(optional=True)
def get_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
return success_response(_businesspolicy_dict(policy))
def _apply_items(policy, items):
"""Replace a business policy's configuration-policy items, validating each."""
policy.items.clear()
seen = set()
for order, row in enumerate(items or []):
cpid = row.get('configurationpolicyid')
if cpid is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'item requires configurationpolicyid')
if cpid in seen:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'duplicate configuration policy {cpid}')
if not db.session.get(ConfigurationPolicy, cpid):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'unknown configuration policy {cpid}')
seen.add(cpid)
policy.items.append(BusinessPolicyItem(
configurationpolicyid=cpid,
sortorder=row.get('sortorder', order),
))
return None
@computers_bp.route('/policies/business', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_business_policy():
gate = _feature_gate()
if gate:
return gate
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
if BusinessPolicy.query.filter_by(name=name).first():
return error_response(ErrorCodes.CONFLICT,
f"Business policy '{name}' already exists",
http_code=409)
policy = BusinessPolicy(
name=name,
description=data.get('description'),
priority=data.get('priority', 100),
source=POLICY_SOURCE_LOCAL,
)
err = _apply_items(policy, data.get('items'))
if err:
return err
db.session.add(policy)
AuditLog.log('created', 'BusinessPolicy', entityname=name)
db.session.commit()
return success_response(_businesspolicy_dict(policy),
message='Business policy created', http_code=201)
@computers_bp.route('/policies/business/<int:policy_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
if BusinessPolicy.query.filter_by(name=newname).first():
return error_response(ErrorCodes.CONFLICT,
f"Business policy '{newname}' already exists",
http_code=409)
policy.name = newname
if 'description' in data:
policy.description = data['description']
if 'priority' in data:
policy.priority = data['priority']
if 'isactive' in data:
policy.isactive = data['isactive']
if 'items' in data:
err = _apply_items(policy, data['items'])
if err:
return err
AuditLog.log('updated', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
return success_response(_businesspolicy_dict(policy),
message='Business policy updated')
@computers_bp.route('/policies/business/<int:policy_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
db.session.delete(policy)
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
return success_response(None, message='Business policy deleted')
# =============================================================================
# Action catalog (for the UI) and effective-policy preview
# =============================================================================
@computers_bp.route('/policies/actions', methods=['GET'])
@jwt_required(optional=True)
def list_policy_actions():
"""The allowlisted action names + labels a policy may reference."""
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')}
for name, meta in COMMAND_ALLOWLIST.items()
])
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_effective_policy(asset_id: int):
"""Preview the resolved effective policy for one endpoint (debug/UI)."""
gate = _feature_gate()
if gate:
return gate
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
return success_response(effective.to_dict())

View File

@@ -62,6 +62,18 @@ export default [
name: 'access-protocols',
component: () => import('./views/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/configuration-policies',
name: 'configuration-policies',
component: () => import('./views/ConfigurationPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/business-policies',
name: 'business-policies',
component: () => import('./views/BusinessPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
}
]

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Priority</th>
<th>Name</th>
<th>Description</th>
<th>Configuration policies</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.businesspolicyid">
<td>{{ p.priority }}</td>
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
{{ it.name }}
</span>
<span v-if="!p.items || p.items.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<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>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
<input v-model.number="form.priority" type="number" class="form-control" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
<div v-for="(it, index) in form.items" :key="index" class="item-row">
<span class="order-badge">{{ index + 1 }}</span>
<select v-model.number="it.configurationpolicyid" class="form-control">
<option :value="undefined" disabled>Select configuration policy...</option>
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
{{ cp.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', priority: 100, items: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [biz, config] = await Promise.all([
computersApi.policies.business.list(),
computersApi.policies.configuration.list()
])
policies.value = biz.data.data || []
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
priority: item.priority ?? 100,
items: (item.items || [])
.slice()
.sort((a, b) => a.sortorder - b.sortorder)
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addItem() {
form.value.items.push({ configurationpolicyid: undefined })
}
function moveUp(index) {
const items = form.value.items
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
}
function moveDown(index) {
const items = form.value.items
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
priority: form.value.priority,
items: form.value.items.map((it, order) => ({
configurationpolicyid: it.configurationpolicyid,
sortorder: order
}))
}
if (editing.value) {
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
} else {
await computersApi.policies.business.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.order-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; border-radius: 12px;
background: var(--border-color, #444);
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Configuration Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Actions in policy</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.configurationpolicyid">
<td>{{ p.name }}</td>
<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 }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Configuration Policy' : 'Add Configuration Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
<div class="scope-builder">
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
</div>
</div>
<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>
<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>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import ScopeNodeEditor from './ScopeNodeEditor.vue'
const toast = useToast()
const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
])
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)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
actions: (item.actions || []).map(a => ({
action: a.action, mode: a.mode || 'gate',
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
}))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addAction() {
form.value.actions.push({ action: '', mode: 'gate' })
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
scopejson: form.value.scopejson,
actions: form.value.actions
}
if (editing.value) {
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
} else {
await computersApi.policies.configuration.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {
await computersApi.policies.configuration.remove(p.configurationpolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.scope-builder {
padding: 10px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.mode-select { max-width: 160px; }
</style>

View File

@@ -444,12 +444,31 @@ watch(() => form.value.serialnumber, (serial) => {
}
})
// Filter models by selected vendor and PC type
// Ensure a saved model id is present in models.value even when the filtered
// list (category PC) excluded it because it has no modeltype. Fetches the
// single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected vendor and PC type. Keep the record's own saved
// model even if it is untyped or a different vendor, so editing an existing PC
// never renders a blank select that clears the field on save.
const filteredModels = computed(() => {
// filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid)
const savedId = form.value.modelnumberid
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
return models.value.filter(
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
})
// Only offer vendors that actually have PC-category models. models is already
@@ -520,6 +539,12 @@ onMounted(async () => {
levelid: pc.levelid ?? null,
ipaddress: primaryComm?.ipaddress || ''
}
// The model list is loaded filtered to category PC, which the backend
// inner-joins against ModelType - so an untyped model is absent. If this
// PC's saved model is one of those rows, fetch it by id and merge it in,
// or its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
}
} catch (err) {
console.error('Error loading data:', err)

View File

@@ -0,0 +1,106 @@
<template>
<div class="scope-node">
<div class="scope-node-row">
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
<option value="true">Matches everything</option>
<option value="pctype">PC type is</option>
<option value="app">App is installed</option>
<option value="and">ALL of (AND)</option>
<option value="or">ANY of (OR)</option>
<option value="not">NOT</option>
</select>
<select v-if="node.type === 'pctype'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select PC type...</option>
<option v-for="pt in pctypes" :key="pt.computertypeid" :value="pt.computertypeid">
{{ pt.computertype }}
</option>
</select>
<select v-if="node.type === 'app'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select app...</option>
<option v-for="app in apps" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<button v-if="removable" type="button" class="btn btn-danger btn-sm" @click="$emit('remove')">
Remove
</button>
</div>
<div v-if="node.type === 'and' || node.type === 'or'" class="scope-children">
<ScopeNodeEditor
v-for="(child, index) in node.nodes"
:key="index"
:node="child"
:pctypes="pctypes"
:apps="apps"
:removable="true"
@remove="removeChild(index)"
/>
<button type="button" class="btn btn-secondary btn-sm" @click="addChild">+ Add condition</button>
</div>
<div v-if="node.type === 'not'" class="scope-children">
<ScopeNodeEditor
:node="node.node"
:pctypes="pctypes"
:apps="apps"
:removable="false"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
node: { type: Object, required: true },
pctypes: { type: Array, default: () => [] },
apps: { type: Array, default: () => [] },
removable: { type: Boolean, default: false }
})
defineEmits(['remove'])
function onTypeChange() {
const node = props.node
// Reset payload keys so a switched node never carries stale fields from its
// previous type. Keep only what the new type needs.
delete node.id
delete node.nodes
delete node.node
if (node.type === 'and' || node.type === 'or') {
node.nodes = [{ type: 'true' }]
} else if (node.type === 'not') {
node.node = { type: 'true' }
}
}
function addChild() {
props.node.nodes.push({ type: 'true' })
}
function removeChild(index) {
props.node.nodes.splice(index, 1)
if (props.node.nodes.length === 0) {
props.node.nodes.push({ type: 'true' })
}
}
</script>
<script>
export default { name: 'ScopeNodeEditor' }
</script>
<style scoped>
.scope-node { margin: 4px 0; }
.scope-node-row { display: flex; gap: 8px; align-items: center; }
.scope-type { max-width: 220px; }
.scope-children {
margin-left: 24px;
padding-left: 12px;
border-left: 2px solid var(--border-color, #444);
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,154 @@
"""Add Configuration and Business Policy tables (RMM-style policies).
Creates configurationpolicies, configurationpolicyactions, businesspolicies,
and businesspolicyitems. These back the DT-authored policy feature that is gated
by the computers_configpolicies_enabled setting; the tables land regardless so
the switch can be flipped without a schema change, and the downgrade drops all
four for a clean rollback of the whole scope.
Idempotent: each create_table is skipped when the table already exists (e.g. a
test DB built by db.create_all()).
Revision ID: computers0004policies
Revises: computers0003azurefields
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0004policies'
down_revision = 'computers0003azurefields'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'configurationpolicies' not in existing:
op.create_table(
'configurationpolicies',
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('scopejson', sa.JSON(), nullable=True),
sa.Column('source', sa.String(length=50), nullable=False,
server_default='local'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('configurationpolicyid'),
sa.UniqueConstraint('name', name='uq_configurationpolicy_name'),
)
op.create_index('idx_configpolicy_source', 'configurationpolicies',
['source'])
op.create_index('idx_configpolicy_active', 'configurationpolicies',
['isactive'])
if 'configurationpolicyactions' not in existing:
op.create_table(
'configurationpolicyactions',
sa.Column('configurationpolicyactionid', sa.Integer(),
nullable=False),
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('mode', sa.String(length=20), nullable=False,
server_default='gate'),
sa.Column('desiredstate', sa.JSON(), nullable=True),
sa.Column('params', sa.JSON(), nullable=True),
sa.Column('intervalminutes', sa.Integer(), nullable=True),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(
['configurationpolicyid'],
['configurationpolicies.configurationpolicyid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('configurationpolicyactionid'),
sa.UniqueConstraint('configurationpolicyid', 'action',
name='uq_configpolicyaction'),
)
op.create_index('idx_configpolicyaction_policy',
'configurationpolicyactions',
['configurationpolicyid'])
if 'businesspolicies' not in existing:
op.create_table(
'businesspolicies',
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=False,
server_default='100'),
sa.Column('source', sa.String(length=50), nullable=False,
server_default='local'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('businesspolicyid'),
sa.UniqueConstraint('name', name='uq_businesspolicy_name'),
)
op.create_index('idx_businesspolicy_priority', 'businesspolicies',
['priority'])
op.create_index('idx_businesspolicy_active', 'businesspolicies',
['isactive'])
if 'businesspolicyitems' not in existing:
op.create_table(
'businesspolicyitems',
sa.Column('businesspolicyitemid', sa.Integer(), nullable=False),
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(
['businesspolicyid'], ['businesspolicies.businesspolicyid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(
['configurationpolicyid'],
['configurationpolicies.configurationpolicyid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('businesspolicyitemid'),
sa.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
name='uq_businesspolicyitem'),
)
op.create_index('idx_bpitem_business', 'businesspolicyitems',
['businesspolicyid'])
op.create_index('idx_bpitem_config', 'businesspolicyitems',
['configurationpolicyid'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'businesspolicyitems' in existing:
op.drop_index('idx_bpitem_config', table_name='businesspolicyitems')
op.drop_index('idx_bpitem_business', table_name='businesspolicyitems')
op.drop_table('businesspolicyitems')
if 'businesspolicies' in existing:
op.drop_index('idx_businesspolicy_active', table_name='businesspolicies')
op.drop_index('idx_businesspolicy_priority', table_name='businesspolicies')
op.drop_table('businesspolicies')
if 'configurationpolicyactions' in existing:
op.drop_index('idx_configpolicyaction_policy',
table_name='configurationpolicyactions')
op.drop_table('configurationpolicyactions')
if 'configurationpolicies' in existing:
op.drop_index('idx_configpolicy_active',
table_name='configurationpolicies')
op.drop_index('idx_configpolicy_source',
table_name='configurationpolicies')
op.drop_table('configurationpolicies')

View File

@@ -7,6 +7,15 @@ from .computer import (
AccessProtocol,
ComputerAccess,
)
from .policy import (
ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy,
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
)
__all__ = [
'Computer',
@@ -14,4 +23,11 @@ __all__ = [
'ComputerInstalledApp',
'AccessProtocol',
'ComputerAccess',
'ConfigurationPolicy',
'ConfigurationPolicyAction',
'BusinessPolicy',
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
]

View File

@@ -0,0 +1,197 @@
"""Configuration and business policy models (RMM-style).
DT staff author reusable Configuration Policies (a single rule binding
allowlisted endpoint actions to endpoints matched by a boolean scope) and group
them into ordered Business Policies assigned to a fleet. When an endpoint reports
in, the server folds the matching Business Policies in priority order to compute
that endpoint's effective policy (first match wins per action).
These models live in the computers plugin so policies sit next to ComputerType.
The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this
layer references action NAMES only. The `source` column is the seam for a future
GE-Enforce sync (local-authored rows vs imported/managed rows).
The whole surface is gated by the computers_configpolicies_enabled setting; the
models register unconditionally (SQLAlchemy needs them), but routes, panels, and
enforcement stay inert while the flag is off, and the migration downgrade drops
every table for a clean rollback.
"""
from shopdb.api import db, BaseModel
# Provenance of a policy row.
# leaves room for `geenforce` (or another manager) to own rows later without a
# schema change - an imported row is read-only in the UI and reconciled by its
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.
Scope is a boolean expression (AND/OR/NOT over PC types and applications)
stored as a small typed JSON AST in scopejson; see services/policyscope.py
for the evaluator. Actions live in ConfigurationPolicyAction rows.
"""
__tablename__ = 'configurationpolicies'
configurationpolicyid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
description = db.Column(db.Text)
# Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3},
# {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side
# (known node types, depth cap) before it is stored - see policyscope.
scopejson = db.Column(db.JSON, nullable=True)
# GE-Enforce interop seam (see POLICY_SOURCE_LOCAL).
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
# createddate, modifieddate, isactive come from BaseModel.
actions = db.relationship(
'ConfigurationPolicyAction',
back_populates='configurationpolicy',
cascade='all, delete-orphan',
)
__table_args__ = (
db.Index('idx_configpolicy_source', 'source'),
db.Index('idx_configpolicy_active', 'isactive'),
)
def __repr__(self):
return f"<ConfigurationPolicy {self.name}>"
# What a policy action does to a matched endpoint.
# desired: reconciled desired-state. desiredstate says what it should be
# (e.g. running=true for mtconnect); the server enqueues the
# drift-correcting command when telemetry disagrees.
# gate: allowlist-gating only. The action is PERMITTED (and shown) on
# matched endpoints; it constrains what may be enqueued, it does not
# drive anything on its own.
ACTION_MODE_DESIRED = 'desired'
ACTION_MODE_GATE = 'gate'
class ConfigurationPolicyAction(BaseModel):
"""One allowlisted action carried by a ConfigurationPolicy.
`action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST;
it is validated against that allowlist at write time. `mode` selects
desired-state reconciliation vs allowlist-gating.
"""
__tablename__ = 'configurationpolicyactions'
configurationpolicyactionid = db.Column(db.Integer, primary_key=True)
configurationpolicyid = db.Column(
db.Integer,
db.ForeignKey('configurationpolicies.configurationpolicyid',
ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name (endpoints commands.py). Not an FK - the allowlist
# is code, not a table - so it is validated in the API layer.
action = db.Column(db.String(100), nullable=False)
mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False)
# Desired-state target for mode=desired, as JSON so different actions can
# express different targets (e.g. {"running": true}). Null for gate mode.
desiredstate = db.Column(db.JSON, nullable=True)
# Optional action parameters passed through to the command (JSON), and an
# optional reconcile cadence in minutes (null = reconcile on every report).
params = db.Column(db.JSON, nullable=True)
intervalminutes = db.Column(db.Integer, nullable=True)
sortorder = db.Column(db.Integer, default=0, nullable=False)
configurationpolicy = db.relationship(
'ConfigurationPolicy', back_populates='actions')
__table_args__ = (
db.UniqueConstraint('configurationpolicyid', 'action',
name='uq_configpolicyaction'),
db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'),
)
def __repr__(self):
return f"<ConfigurationPolicyAction {self.action} mode={self.mode}>"
class BusinessPolicy(BaseModel):
"""An ordered collection of Configuration Policies assigned to a fleet.
Priority orders Business Policies against each other when several match one
endpoint: lower priority number is evaluated first, and the first policy to
speak on a given action name wins (first-match-wins per action).
"""
__tablename__ = 'businesspolicies'
businesspolicyid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
description = db.Column(db.Text)
# Lower runs first. Ties broken by businesspolicyid for a stable order.
priority = db.Column(db.Integer, default=100, nullable=False)
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
# createddate, modifieddate, isactive come from BaseModel.
items = db.relationship(
'BusinessPolicyItem',
back_populates='businesspolicy',
cascade='all, delete-orphan',
order_by='BusinessPolicyItem.sortorder',
)
__table_args__ = (
db.Index('idx_businesspolicy_priority', 'priority'),
db.Index('idx_businesspolicy_active', 'isactive'),
)
def __repr__(self):
return f"<BusinessPolicy {self.name} priority={self.priority}>"
class BusinessPolicyItem(BaseModel):
"""Membership of a Configuration Policy in a Business Policy, ordered.
sortorder sets the walk order WITHIN a Business Policy; combined with the
Business Policy priority it gives a fully deterministic fold order for the
effective-policy resolver.
"""
__tablename__ = 'businesspolicyitems'
businesspolicyitemid = db.Column(db.Integer, primary_key=True)
businesspolicyid = db.Column(
db.Integer,
db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'),
nullable=False,
)
configurationpolicyid = db.Column(
db.Integer,
db.ForeignKey('configurationpolicies.configurationpolicyid',
ondelete='CASCADE'),
nullable=False,
)
sortorder = db.Column(db.Integer, default=0, nullable=False)
businesspolicy = db.relationship('BusinessPolicy', back_populates='items')
configurationpolicy = db.relationship('ConfigurationPolicy')
__table_args__ = (
db.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
name='uq_businesspolicyitem'),
db.Index('idx_bpitem_business', 'businesspolicyid'),
db.Index('idx_bpitem_config', 'configurationpolicyid'),
)
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")

View File

@@ -12,8 +12,16 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from .models import (
ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy,
BusinessPolicyItem,
)
from .api import computers_bp
from .policyflag import policy_flag_enabled as _policy_flag_enabled
logger = logging.getLogger(__name__)
# Marker stamped on PC->measuringtool "controls" links this collector creates
@@ -92,7 +100,8 @@ class ComputersPlugin(BasePlugin):
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess]
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess,
ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
@@ -397,6 +406,36 @@ class ComputersPlugin(BasePlugin):
'site\'s URL and ranges',
'position': 26,
},
] + self._policy_settings_cards()
def _policy_settings_cards(self):
"""Policy admin cards, shown only while the feature flag is on.
Gating the cards on the switch keeps the settings menu honest: the
Configuration/Business Policy screens appear exactly when the feature is
enabled and vanish on rollback, matching the 404 the routes return.
"""
if not self._configpolicies_enabled_safe():
return []
return [
{
'group': 'Computers',
'to': '/settings/configuration-policies',
'icon': 'shield',
'title': 'Configuration policies',
'description': 'Reusable rules binding endpoint actions to PC '
'types and applications',
'position': 27,
},
{
'group': 'Computers',
'to': '/settings/business-policies',
'icon': 'layers',
'title': 'Business policies',
'description': 'Ordered collections of configuration policies '
'assigned by priority',
'position': 28,
},
]
def get_settings_defaults(self) -> List[dict]:
@@ -438,8 +477,41 @@ class ComputersPlugin(BasePlugin):
'Off while machine numbers are shared between '
'PCs; the collector response still warns.',
},
{
'key': 'computers_configpolicies_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'computers',
'description': 'Master switch for RMM-style Configuration and '
'Business 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.',
},
]
def _configpolicies_enabled(self):
"""Whether the Configuration/Business Policy feature is turned on.
Off by default. This is the single exit gate for the policy scope: the
tables and models load regardless (SQLAlchemy needs them), but every
route, panel, and enforcement path checks this first and no-ops while it
is false, so the feature can be disabled without a migration.
"""
return _policy_flag_enabled()
def _configpolicies_enabled_safe(self):
"""Flag read that never raises when the settings table is absent.
get_settings_cards can be called in contexts without a database (the
plugin contract tests build hooks against a bare app), so a missing
settings table must read as "off" rather than propagate.
"""
try:
return _policy_flag_enabled()
except Exception:
return False
def _machinelink_alerts_enabled(self):
"""Whether the PC-to-machine alerts may be sent.

View File

@@ -0,0 +1,19 @@
"""Single source of truth for the Configuration/Business Policy feature gate.
Kept in its own module (not on the plugin class) so the CRUD routes, the panel
surface, and the endpoints enforcement path can all consult the same switch
without importing ComputersPlugin. The flag is the computers_configpolicies_enabled
Setting row seeded by the plugin's get_settings_defaults; while it is off, every
policy code path no-ops, which is the exit gate for rolling the feature back.
"""
def policy_flag_enabled():
"""True when computers_configpolicies_enabled is set truthy."""
from shopdb.api import Setting
setting = Setting.query.filter_by(
key='computers_configpolicies_enabled').first()
if not setting:
return False
return (setting.value or '').strip().lower() in ('true', '1', 'yes')

View File

@@ -0,0 +1 @@
"""Computers plugin services: pure policy logic (scope + resolution)."""

View File

@@ -0,0 +1,168 @@
"""Effective-policy resolution: the single swappable policy entry point.
This module answers one question for the rest of the system: "given an endpoint,
what actions apply and how?" It is the boundary the endpoints allowlist seam
calls - never the policy tables directly - so the policy ASSEMBLY ENGINE can be
swapped without touching enforcement. Today it folds the local computers-plugin
tables; a future GE-Enforce integration can replace build_effective_policy (or
feed it) and every consumer keeps working unchanged.
Resolution model (per the design):
- Build an endpoint context (PC type + installed appids) from the Computer row.
- Walk every active Business Policy in priority order (lower first, ties by id).
- Within each, walk its Configuration Policies in sortorder.
- A Configuration Policy contributes only if its scope matches the endpoint.
- For each action name, FIRST MATCH WINS: once an action is decided it is not
overridden by a later, lower-priority policy.
The result is an EffectivePolicy: a flat, resolved map of action name -> decision
(mode desired/gate, plus desiredstate/params), which is exactly what enforcement
and the allowlist need.
"""
from .policyscope import evaluate_scope
class EffectiveAction:
"""One resolved action decision for an endpoint."""
__slots__ = ('action', 'mode', 'desiredstate', 'params', 'intervalminutes',
'businesspolicyid', 'configurationpolicyid')
def __init__(self, action, mode, desiredstate, params, intervalminutes,
businesspolicyid, configurationpolicyid):
self.action = action
self.mode = mode
self.desiredstate = desiredstate
self.params = params
self.intervalminutes = intervalminutes
self.businesspolicyid = businesspolicyid
self.configurationpolicyid = configurationpolicyid
def to_dict(self):
return {
'action': self.action,
'mode': self.mode,
'desiredstate': self.desiredstate,
'params': self.params,
'intervalminutes': self.intervalminutes,
'businesspolicyid': self.businesspolicyid,
'configurationpolicyid': self.configurationpolicyid,
}
class EffectivePolicy:
"""The folded, resolved policy for a single endpoint.
actions maps action name -> EffectiveAction (first-match-wins already
applied). Convenience views expose the gate allowlist and desired-state set
that enforcement and the endpoints seam consume.
"""
def __init__(self, actions):
self.actions = actions
def allowed_actions(self):
"""Set of action names permitted on this endpoint (gate + desired)."""
return set(self.actions.keys())
def desired_actions(self):
"""action name -> EffectiveAction for mode == 'desired' only."""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired'}
def to_dict(self):
return {name: eff.to_dict() for name, eff in self.actions.items()}
def build_endpoint_context(computer):
"""Resolve a Computer row into the context the scope evaluator needs.
Returns {'computertypeid': int|None, 'appids': set[int]}.
"""
appids = set()
for link in getattr(computer, 'installedapps', None) or ():
if getattr(link, 'isactive', True) and link.appid is not None:
appids.add(link.appid)
return {
'computertypeid': getattr(computer, 'computertypeid', None),
'appids': appids,
}
def _iter_matching_configpolicies(business_policies, context):
"""Yield (businesspolicyid, ConfigurationPolicy) in deterministic order.
Business Policies by (priority, businesspolicyid); their items by sortorder.
Only active policies whose scope matches the context are yielded.
"""
ordered_bp = sorted(
business_policies,
key=lambda bp: (bp.priority, bp.businesspolicyid),
)
for business_policy in ordered_bp:
if not business_policy.isactive:
continue
for item in sorted(business_policy.items,
key=lambda it: (it.sortorder, it.businesspolicyitemid)):
config_policy = item.configurationpolicy
if config_policy is None or not config_policy.isactive:
continue
if evaluate_scope(config_policy.scopejson, context):
yield business_policy.businesspolicyid, config_policy
def build_effective_policy(computer, business_policies):
"""Fold matching policies into an EffectivePolicy for one endpoint.
computer: the Computer row. business_policies: iterable of BusinessPolicy
(with items/configurationpolicy loaded). Pure given its inputs so it stays
unit-testable and reusable by an alternate assembly engine.
"""
context = build_endpoint_context(computer)
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
name = action_row.action
if name in resolved:
# First match wins - a higher-priority policy already decided
# this action; later policies do not override it.
continue
resolved[name] = EffectiveAction(
action=name,
mode=action_row.mode,
desiredstate=action_row.desiredstate,
params=action_row.params,
intervalminutes=action_row.intervalminutes,
businesspolicyid=businesspolicyid,
configurationpolicyid=config_policy.configurationpolicyid,
)
return EffectivePolicy(resolved)
def effective_policy_for(assetid):
"""Resolve the effective policy for an endpoint by its assetid.
This is the public entry point the endpoints plugin calls. It returns an
empty EffectivePolicy when the feature is off or the asset is not a known
computer, so callers get a safe "no policy" answer without special-casing.
NOTE for GE-Enforce interop: this function is the seam. To hand policy
assembly to GE-Enforce later, replace the body below (or dispatch on source)
while keeping this signature and the EffectivePolicy return type intact.
"""
from ..policyflag import policy_flag_enabled
if not policy_flag_enabled():
return EffectivePolicy({})
from ..models import Computer, BusinessPolicy
computer = Computer.query.filter_by(assetid=assetid).first()
if computer is None:
return EffectivePolicy({})
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)

View File

@@ -0,0 +1,96 @@
"""Boolean scope evaluation for Configuration Policies.
A policy's scopejson is a small typed boolean AST over two endpoint dimensions,
PC type and installed application. This module is the ONLY place that interprets
that AST: it validates the shape (known node types, bounded depth) and evaluates
a validated tree against a resolved endpoint context.
Node grammar (all dict nodes carry a "type"):
{"type": "and", "nodes": [<node>, ...]}
{"type": "or", "nodes": [<node>, ...]}
{"type": "not", "node": <node>}
{"type": "pctype", "id": <computertypeid:int>}
{"type": "app", "id": <appid:int>}
{"type": "true"} # matches everything (an unscoped/global policy)
Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested
and so a future GE-Enforce assembly engine can reuse the exact same evaluator on
the exact same AST when it composes policies.
"""
# Bound the tree so a hand-crafted or imported AST cannot cause pathological
# recursion. Ten levels is far past any policy a human would author.
MAX_SCOPE_DEPTH = 10
_LOGICAL = {'and', 'or', 'not'}
_LEAF = {'pctype', 'app', 'true'}
class ScopeError(ValueError):
"""Raised when a scope AST is malformed. Carries a human-readable reason."""
def validate_scope(node, _depth=0):
"""Validate a scope AST, raising ScopeError on any problem.
Returns None on success. Call this at write time (CRUD) so a bad tree never
reaches the database; evaluate_scope then trusts the stored shape.
"""
if _depth > MAX_SCOPE_DEPTH:
raise ScopeError(f'scope nested deeper than {MAX_SCOPE_DEPTH} levels')
if not isinstance(node, dict):
raise ScopeError('scope node must be an object')
ntype = node.get('type')
if ntype not in _LOGICAL and ntype not in _LEAF:
raise ScopeError(f'unknown scope node type: {ntype!r}')
if ntype in ('and', 'or'):
nodes = node.get('nodes')
if not isinstance(nodes, list) or not nodes:
raise ScopeError(f'{ntype} requires a non-empty "nodes" list')
for child in nodes:
validate_scope(child, _depth + 1)
elif ntype == 'not':
child = node.get('node')
if child is None:
raise ScopeError('not requires a "node"')
validate_scope(child, _depth + 1)
elif ntype in ('pctype', 'app'):
ident = node.get('id')
if not isinstance(ident, int) or isinstance(ident, bool):
raise ScopeError(f'{ntype} requires an integer "id"')
# 'true' carries no payload.
def evaluate_scope(node, context):
"""Evaluate a validated scope AST against an endpoint context.
context is expected to expose:
context['computertypeid'] -> int or None
context['appids'] -> set/collection of installed appids
Returns True when the endpoint falls in scope. An empty/None scope means the
policy is unscoped and matches everything (same as {"type": "true"}), which
keeps a freshly-created policy usable before a scope is drawn.
"""
if node is None:
return True
ntype = node['type']
if ntype == 'true':
return True
if ntype == 'and':
return all(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'or':
return any(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'not':
return not evaluate_scope(node['node'], context)
if ntype == 'pctype':
return context.get('computertypeid') == node['id']
if ntype == 'app':
return node['id'] in (context.get('appids') or ())
# validate_scope should have caught this; be strict rather than silently
# matching an endpoint against an unknown node.
raise ScopeError(f'unknown scope node type at evaluation: {ntype!r}')