Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
@@ -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']
|
||||
|
||||
399
plugins/computers/api/policy_routes.py
Normal file
399
plugins/computers/api/policy_routes.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""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 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'])
|
||||
@require_permission('computers.view')
|
||||
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'])
|
||||
@require_permission('computers.view')
|
||||
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'])
|
||||
@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'])
|
||||
@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'])
|
||||
@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'])
|
||||
@require_permission('computers.view')
|
||||
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'])
|
||||
@require_permission('computers.view')
|
||||
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'])
|
||||
@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'])
|
||||
@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'])
|
||||
@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'])
|
||||
@require_permission('computers.view')
|
||||
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'])
|
||||
@require_permission('computers.view')
|
||||
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())
|
||||
@@ -575,6 +575,9 @@ def create_computer():
|
||||
vendorid=data.get('vendorid'),
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
loggedinuser=data.get('loggedinuser'),
|
||||
azuredeviceid=data.get('azuredeviceid'),
|
||||
azureobjectid=data.get('azureobjectid'),
|
||||
azuredevicecategory=data.get('azuredevicecategory'),
|
||||
lastreporteddate=data.get('lastreporteddate'),
|
||||
lastboottime=data.get('lastboottime')
|
||||
)
|
||||
@@ -678,8 +681,9 @@ def update_computer(computer_id: int):
|
||||
|
||||
# Update computer fields
|
||||
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
|
||||
'modelnumberid', 'loggedinuser', 'lastreporteddate',
|
||||
'lastboottime']
|
||||
'modelnumberid', 'loggedinuser', 'azuredeviceid',
|
||||
'azureobjectid', 'azuredevicecategory',
|
||||
'lastreporteddate', 'lastboottime']
|
||||
for key in computer_fields:
|
||||
if key in data:
|
||||
old_val = getattr(comp, key)
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal 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>
|
||||
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal 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>
|
||||
@@ -3,6 +3,8 @@
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
|
||||
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
@@ -153,6 +155,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entra / Azure device info -->
|
||||
<div
|
||||
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
|
||||
class="section-card"
|
||||
>
|
||||
<h3 class="section-title">Entra Device Info</h3>
|
||||
<div class="info-list">
|
||||
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
||||
<span class="info-label">Device ID</span>
|
||||
<span class="info-value">{{ computer.computer.azuredeviceid }}</span>
|
||||
</div>
|
||||
<div v-if="computer.computer?.azureobjectid" class="info-row">
|
||||
<span class="info-label">Object ID</span>
|
||||
<span class="info-value">{{ computer.computer.azureobjectid }}</span>
|
||||
</div>
|
||||
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
|
||||
<span class="info-label">Device Category</span>
|
||||
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
@@ -226,7 +250,7 @@
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<PluginAssetPanels :assetid="computer.assetid" />
|
||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
@@ -253,10 +277,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -267,15 +291,26 @@ import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
const installedApps = ref([])
|
||||
const assetPanels = ref(null)
|
||||
const restarting = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// Header endpoint controls only make sense to users who can act on the agent.
|
||||
// Restart is gated separately because it is a destructive grant (endpoints.restart)
|
||||
// enforced again server-side; hiding the button is convenience, not the guard.
|
||||
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
|
||||
// Warranty fetch + hero badge (shared across asset detail pages).
|
||||
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
||||
@@ -316,6 +351,108 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Force a live telemetry cycle rather than re-reading the cached snapshot.
|
||||
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
|
||||
// finish it, then re-fetches the panels so the cards reflect the endpoint's
|
||||
// real current state (e.g. a process the operator just closed).
|
||||
async function refreshStatus() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
|
||||
// command reaching 'succeeded' only means the agent ran collect_now, not
|
||||
// that it has posted the fresh snapshot yet, so we additionally wait for
|
||||
// lastreportedat to move past this baseline before re-rendering the panels.
|
||||
const baseline = await currentTelemetryStamp()
|
||||
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
const outcome = await waitForCommand(commandId)
|
||||
const fresh = outcome === 'succeeded'
|
||||
? await waitForFreshTelemetry(baseline)
|
||||
: false
|
||||
await assetPanels.value?.refresh()
|
||||
if (outcome === 'timeout') {
|
||||
toast.info('Refresh queued; the endpoint agent may be offline or busy')
|
||||
} else if (outcome === 'failed') {
|
||||
toast.error('The endpoint agent could not refresh telemetry')
|
||||
} else if (!fresh) {
|
||||
toast.info('Refresh sent; the endpoint has not reported new data yet')
|
||||
} else {
|
||||
toast.success('Status refreshed')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to refresh status'))
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
|
||||
// freshness baseline; a null baseline means the box has never reported, so any
|
||||
// non-null stamp later counts as fresh.
|
||||
async function currentTelemetryStamp() {
|
||||
try {
|
||||
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
|
||||
return response?.data?.data?.lastreportedat || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
|
||||
// landed) or the attempt budget runs out. Returns true only on a confirmed
|
||||
// newer stamp; a bounded false lets the caller still refresh and message that
|
||||
// the box has not reported yet, rather than hanging the button on an idle agent.
|
||||
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
const baseMs = baseline ? Date.parse(baseline) : 0
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
const stamp = await currentTelemetryStamp()
|
||||
if (stamp && Date.parse(stamp) > baseMs) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Poll the endpoint's recent commands until the given command reaches a terminal
|
||||
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
|
||||
// long-polls, so a live box turns this around in a couple of seconds; an offline
|
||||
// box lets it time out cleanly rather than hanging the button forever.
|
||||
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
if (!commandId) return 'timeout'
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(computer.value.assetid)
|
||||
const command = (response?.data?.data || []).find(
|
||||
(row) => row.endpointcommandid === commandId)
|
||||
if (command && command.status === 'succeeded') return 'succeeded'
|
||||
if (command && command.status === 'failed') return 'failed'
|
||||
} catch {
|
||||
// Transient poll failure: keep trying until the attempt budget runs out.
|
||||
}
|
||||
}
|
||||
return 'timeout'
|
||||
}
|
||||
|
||||
// Destructive: queues a system_restart that reboots the PC immediately
|
||||
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
|
||||
// unrecoverable action; the server re-checks endpoints.restart regardless.
|
||||
async function confirmRestart() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Restart ${label} now?\n\n`
|
||||
+ 'The computer reboots immediately with no grace period. '
|
||||
+ 'Any unsaved work on it will be lost.')) return
|
||||
restarting.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
|
||||
toast.success(`Restart queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to queue restart'))
|
||||
} finally {
|
||||
restarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -223,6 +223,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="azuredeviceid">Azure Device ID</label>
|
||||
<input
|
||||
id="azuredeviceid"
|
||||
v-model="form.azuredeviceid"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Remote Access Protocols</label>
|
||||
<div class="protocol-list">
|
||||
@@ -383,6 +394,7 @@ const form = ref({
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
azuredeviceid: '',
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
mapx: null,
|
||||
@@ -432,12 +444,42 @@ 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
|
||||
// scoped to category PC at load, so its vendorids are the PC vendor set. Keep
|
||||
// the record's own vendor even if it falls outside that set, so editing an
|
||||
// existing PC never silently drops its saved vendor.
|
||||
const filteredVendors = computed(() => {
|
||||
const pcVendorIds = new Set(models.value.map(m => m.vendorid))
|
||||
return vendors.value.filter(
|
||||
v => pcVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -448,8 +490,8 @@ onMounted(async () => {
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
@@ -459,7 +501,7 @@ onMounted(async () => {
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
protocols.value = protoRes.data.data || []
|
||||
|
||||
@@ -486,6 +528,7 @@ onMounted(async () => {
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
azuredeviceid: ext.azuredeviceid || '',
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
@@ -496,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)
|
||||
@@ -555,6 +604,7 @@ async function savePC() {
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
azuredeviceid: form.value.azuredeviceid || null,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
|
||||
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal file
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal 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>
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add azuredeviceid to computers (Entra/Azure device-group imports).
|
||||
|
||||
The core chain created computers WITHOUT an azuredeviceid column. This plugin
|
||||
revision adds it so BOTH fresh installs (core chain builds the table, then this
|
||||
adds the column) and existing installs get it. Guarded/idempotent: skips when
|
||||
the table is absent (plugin disabled) or the column already exists (e.g. a test
|
||||
DB built by db.create_all() from the model).
|
||||
|
||||
Revision ID: computers0002azureid
|
||||
Revises: computers0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'computers0002azureid'
|
||||
down_revision = 'computers0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azuredeviceid' not in cols:
|
||||
op.add_column('computers',
|
||||
sa.Column('azuredeviceid', sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azuredeviceid' in cols:
|
||||
op.drop_column('computers', 'azuredeviceid')
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add azureobjectid and azuredevicecategory to computers.
|
||||
|
||||
The Azure device-group import now also records the Entra directory object id
|
||||
(the CSV 'id' column) and an operator-supplied device category (the group
|
||||
membership label, e.g. SFLD_DYN_Device_Group_...). This revision adds both
|
||||
columns. Guarded/idempotent: skips when the table is absent (plugin disabled)
|
||||
or a column already exists (e.g. a test DB built by db.create_all()).
|
||||
|
||||
Revision ID: computers0003azurefields
|
||||
Revises: computers0002azureid
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'computers0003azurefields'
|
||||
down_revision = 'computers0002azureid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azureobjectid' not in cols:
|
||||
op.add_column('computers',
|
||||
sa.Column('azureobjectid', sa.String(length=64), nullable=True))
|
||||
if 'azuredevicecategory' not in cols:
|
||||
op.add_column('computers',
|
||||
sa.Column('azuredevicecategory', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azuredevicecategory' in cols:
|
||||
op.drop_column('computers', 'azuredevicecategory')
|
||||
if 'azureobjectid' in cols:
|
||||
op.drop_column('computers', 'azureobjectid')
|
||||
162
plugins/computers/migrations/versions/0004_policies.py
Normal file
162
plugins/computers/migrations/versions/0004_policies.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""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
|
||||
|
||||
|
||||
# TEXT rather than sa.JSON on purpose. MySQL 5.6 has no JSON type - it arrived
|
||||
# in 5.7 - and this migration failed there with a bare syntax error near the
|
||||
# word JSON. Every other JSON-bearing column in the product is TEXT for the same
|
||||
# reason (guides.blocksjson, geenforce.manifestjson); the model reads and writes
|
||||
# dicts through the JSONText type decorator, so nothing above the database
|
||||
# changes.
|
||||
|
||||
|
||||
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.Text(), 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.Text(), nullable=True),
|
||||
sa.Column('params', sa.Text(), 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')
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -85,6 +85,23 @@ class Computer(BaseModel):
|
||||
lastreporteddate = db.Column(db.DateTime, nullable=True)
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Entra/Azure device identity (set by Azure device-group imports)
|
||||
azuredeviceid = db.Column(
|
||||
db.String(64),
|
||||
nullable=True,
|
||||
comment='Entra/Azure deviceId GUID'
|
||||
)
|
||||
azureobjectid = db.Column(
|
||||
db.String(64),
|
||||
nullable=True,
|
||||
comment='Entra/Azure directory object id GUID'
|
||||
)
|
||||
azuredevicecategory = db.Column(
|
||||
db.String(255),
|
||||
nullable=True,
|
||||
comment='Entra device category / group membership label set at import'
|
||||
)
|
||||
|
||||
# Remote access is now modeled per-protocol via the accessmethods
|
||||
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.
|
||||
|
||||
|
||||
44
plugins/computers/models/jsontext.py
Normal file
44
plugins/computers/models/jsontext.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""JSON stored in a TEXT column.
|
||||
|
||||
MySQL 5.6 has no JSON type - it arrived in 5.7 - and a site is still running
|
||||
5.6, where `sa.JSON()` fails at migration time with a bare syntax error near the
|
||||
word JSON. Every other JSON-bearing column in this product is already TEXT for
|
||||
that reason: guides' `blocksjson`, GE-Enforce's `manifestjson`.
|
||||
|
||||
This keeps the Python side identical. A model attribute still holds a dict or a
|
||||
list, still assigns and reads like one; only the storage changes. The
|
||||
alternative - json.dumps and json.loads at seventeen call sites - is the same
|
||||
thing written out seventeen times, with seventeen chances to forget one.
|
||||
|
||||
Note the SQLite behaviour is deliberately the same as MySQL's. Tests run on
|
||||
SQLite and production does not, so a type that behaved differently across the
|
||||
two would hide exactly the failure this exists to prevent.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
|
||||
class JSONText(TypeDecorator):
|
||||
"""A dict or list on the Python side, TEXT in the database."""
|
||||
|
||||
impl = Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, separators=(',', ':'))
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None or value == '':
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
# A row written before this column was JSON, or hand-edited. Hand
|
||||
# back the raw text rather than raising: a policy that cannot be
|
||||
# parsed should be visible and fixable, not an exception on read.
|
||||
return value
|
||||
198
plugins/computers/models/policy.py
Normal file
198
plugins/computers/models/policy.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
from .jsontext import JSONText
|
||||
|
||||
|
||||
# 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(JSONText, 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(JSONText, 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(JSONText, 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}>")
|
||||
@@ -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
|
||||
@@ -32,6 +40,14 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
# touched.
|
||||
MACHINE_LINK_ORIGIN = 'collector:machine'
|
||||
|
||||
# Marker stamped on the PC->machine link built from a CSV import's machine
|
||||
# number column. A DISTINCT origin from the collector's so the two provenances
|
||||
# stay separable: an import establishes a link once from a spreadsheet, the
|
||||
# collector maintains it continuously from the floor, and a site may use one,
|
||||
# the other, or both. Same archive discipline - only rows carrying this label
|
||||
# are touched by a re-import.
|
||||
MACHINE_LINK_IMPORT_ORIGIN = 'import:machine'
|
||||
|
||||
# Marker stamped on both links a part-marker PC produces: PC controls marker,
|
||||
# and marker partof the operation it serves. Same archive discipline again.
|
||||
PARTMARKER_LINK_ORIGIN = 'collector:partmarker'
|
||||
@@ -84,12 +100,229 @@ 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."""
|
||||
self._register_import_hook()
|
||||
logger.info(f"Computers plugin initialized (v{self.meta.version})")
|
||||
|
||||
# -- bulk-import machine link -------------------------------------------
|
||||
|
||||
def _register_import_hook(self):
|
||||
"""Own a `machineassetnumber` column on computers.csv.
|
||||
|
||||
A site with a spreadsheet knows "this PC drives machine 3015" and wants
|
||||
to write that number in the PC row, not hand-build an assetrelationships
|
||||
sheet keyed on ids it never sees. The core importer stays ignorant of
|
||||
what the column means: it accepts the header, keeps it out of the
|
||||
computers write, and hands the value to this hook after each PC row is
|
||||
written, so the PC's asset id exists. Registration is idempotent, so a
|
||||
plugin reload or a test re-init does not double-fire.
|
||||
"""
|
||||
from shopdb.api import register_import_hook
|
||||
|
||||
register_import_hook(
|
||||
'computers',
|
||||
{'machineassetnumber':
|
||||
'the asset number of the machine this PC drives (from '
|
||||
'machines.csv); builds the PC-controls-machine link. A number '
|
||||
'that has no machine, or one already driven by another PC, is '
|
||||
'reported as a warning and skipped - the PC still imports.'},
|
||||
self._import_machine_link)
|
||||
|
||||
def _import_machine_link(self, rowvalues, virtualvalues, line):
|
||||
"""Build a PC->machine controls link from an import row's machine number.
|
||||
|
||||
Runs inside the importer's transaction - a dry run rolls it back - so it
|
||||
reuses the collector's mature _sync_machine_link rather than inventing a
|
||||
second linker, but under the import origin (so an import never archives
|
||||
a collector's rows and vice versa) and in preview mode (so a bulk import
|
||||
never emails an alert per row; the findings are the report's amber
|
||||
warnings instead).
|
||||
|
||||
Returns RowWarning list, never raises: a machine number that resolves to
|
||||
no asset is an amber note beside a PC that still imports, not a failed
|
||||
file. Every warning names the PC, the number, and the one fix.
|
||||
"""
|
||||
from shopdb.api import RowWarning
|
||||
|
||||
machinenumber = (virtualvalues.get('machineassetnumber') or '').strip()
|
||||
if not machinenumber:
|
||||
return []
|
||||
|
||||
assetid = rowvalues.get('assetid')
|
||||
comp = Computer.query.filter_by(assetid=assetid).first() if assetid else None
|
||||
if comp is None:
|
||||
# The PC row was written but its Computer extension is not readable
|
||||
# here - nothing to link to. Amber, not fatal.
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='PC row not found to link to machine {!r}'.format(
|
||||
machinenumber),
|
||||
action='re-run the import; if it recurs, check the computers '
|
||||
'row imported',
|
||||
target=machinenumber)]
|
||||
|
||||
hostname = comp.hostname or (assetid and str(assetid)) or '?'
|
||||
|
||||
# IN-FILE FAN-OUT GUARD. Two rows in one computers.csv both naming the
|
||||
# same machine is a spreadsheet mistake - a copied cell, a wrong
|
||||
# workstation number - not a swap. But _incumbent_has_yielded reads a
|
||||
# freshly-imported PC (no lastreporteddate) as having yielded, so
|
||||
# without this the SECOND row would silently archive the first row's
|
||||
# link and steal the machine, and the file would import clean with the
|
||||
# wrong PC on the bay. So the first claim on a machine this run wins and
|
||||
# is remembered; a later row for the same machine is warned and skipped.
|
||||
# The claim set lives on db.session.info, which Flask-SQLAlchemy resets
|
||||
# at request teardown, so it is scoped to exactly this one import and
|
||||
# never leaks into the next.
|
||||
machine = self._asset_by_number(machinenumber)
|
||||
if machine is not None and machine.assetid == assetid:
|
||||
# The number names this very PC. Happens on a PC first seen before
|
||||
# the machine-number convention, whose own asset number IS the
|
||||
# machine number. Skip - a PC does not control itself.
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='machine number {!r} is this PC\'s own asset number; '
|
||||
'not linked'.format(machinenumber),
|
||||
action='give this PC a hostname-based asset number, or point '
|
||||
'machineassetnumber at the machine it drives',
|
||||
subject=hostname, target=machinenumber)]
|
||||
|
||||
if machine is not None:
|
||||
# A location-only machine is a virtual bay marker, not a real
|
||||
# machine, so nothing controls it. Skip rather than build a link
|
||||
# that would read as a PC driving a location.
|
||||
skip = self._location_only_skip(
|
||||
machine, hostname, machinenumber, line)
|
||||
if skip is not None:
|
||||
return [skip]
|
||||
|
||||
if machine is not None and machine.assetid != assetid:
|
||||
# ALREADY-CONTROLS-DIFFERENT. Unlike the collector - which reads a
|
||||
# new machine number as a PC moved to a new bay and silently
|
||||
# re-bays it - an import naming a machine when this PC already
|
||||
# actively controls a DIFFERENT one is far more likely a wrong
|
||||
# workstation number in the sheet than a real move. Warn and skip
|
||||
# rather than archive a link a person may have set on purpose.
|
||||
other = self._active_controlled_machine(assetid, machine.assetid)
|
||||
if other is not None:
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='{} already controls machine {}; not moved to {}'
|
||||
.format(hostname, other, machine.assetnumber),
|
||||
action='if this PC really moved, clear the existing link '
|
||||
'first; otherwise correct the machine number on '
|
||||
'this row',
|
||||
subject=hostname, target=machinenumber)]
|
||||
|
||||
if machine is not None and machine.assetid != assetid:
|
||||
claimed = db.session.info.setdefault('import_machine_claims', {})
|
||||
# Keyed by machine asset id -> (claimant PC asset id, its hostname).
|
||||
# The id is what the guard compares on (the same PC re-stating its
|
||||
# own link is not a conflict); the hostname is only for the message.
|
||||
firstclaimant = claimed.get(machine.assetid)
|
||||
if firstclaimant is not None and firstclaimant[0] != assetid:
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='machine {} was already claimed by {} earlier in '
|
||||
'this import; {} not linked'.format(
|
||||
machine.assetnumber, firstclaimant[1], hostname),
|
||||
action='a machine drives one PC - correct the machine '
|
||||
'number on one of these rows so each PC names its '
|
||||
'own machine',
|
||||
subject=hostname, target=machinenumber)]
|
||||
claimed[machine.assetid] = (assetid, hostname)
|
||||
|
||||
# _sync_machine_link appends plain strings to its warnings list. Collect
|
||||
# them and translate to structured, actionable RowWarnings so the UI can
|
||||
# show the PC, the machine number, and the corrective step.
|
||||
rawwarnings = []
|
||||
self._sync_machine_link(
|
||||
comp, machinenumber, rawwarnings,
|
||||
origin_label=MACHINE_LINK_IMPORT_ORIGIN, preview=True)
|
||||
|
||||
out = []
|
||||
for text in rawwarnings:
|
||||
out.append(RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason=text,
|
||||
action=self._machine_link_action(text, machinenumber),
|
||||
subject=hostname, target=machinenumber))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _active_controlled_machine(pcassetid, excludeassetid):
|
||||
"""Asset number of a machine this PC actively controls under the import
|
||||
origin, other than excludeassetid, or None.
|
||||
|
||||
Scoped to MACHINE_LINK_IMPORT_ORIGIN so an import only reasons about its
|
||||
own links: a collector link or a hand-made one is the collector's or a
|
||||
person's to move, not the import's to block on.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls:
|
||||
return None
|
||||
rel = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcassetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_IMPORT_ORIGIN,
|
||||
AssetRelationship.targetassetid != excludeassetid,
|
||||
AssetRelationship.isactive.is_(True),
|
||||
).first()
|
||||
if rel is None:
|
||||
return None
|
||||
asset = db.session.get(Asset, rel.targetassetid)
|
||||
return asset.assetnumber if asset else str(rel.targetassetid)
|
||||
|
||||
@staticmethod
|
||||
def _location_only_skip(machine, hostname, machinenumber, line):
|
||||
"""A RowWarning if the resolved asset is a location-only marker, else None.
|
||||
|
||||
A machine flagged islocationonly is a virtual bay, not a real machine,
|
||||
so a PC does not control it. Guarded behind the machines plugin being
|
||||
installed - a site without it cannot have a location-only machine, and
|
||||
the import must not fail because the model is absent.
|
||||
"""
|
||||
from shopdb.api import RowWarning
|
||||
try:
|
||||
from plugins.machines.models import Machine
|
||||
except ImportError:
|
||||
return None
|
||||
row = Machine.query.filter_by(assetid=machine.assetid).first()
|
||||
if row is None or not row.islocationonly:
|
||||
return None
|
||||
return RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='machine {} is a location-only marker, not a real machine; '
|
||||
'not linked'.format(machine.assetnumber),
|
||||
action='point machineassetnumber at a real machine, or clear it '
|
||||
'for this PC',
|
||||
subject=hostname, target=machinenumber)
|
||||
|
||||
@staticmethod
|
||||
def _machine_link_action(text, machinenumber):
|
||||
"""The one corrective step for a machine-link warning, by its shape."""
|
||||
lowered = text.lower()
|
||||
if 'no asset for machine number' in lowered:
|
||||
return ('create the machine asset with number {!r} (import '
|
||||
'machines.csv first), or correct the number on this PC '
|
||||
'row'.format(machinenumber))
|
||||
if 'still held by' in lowered:
|
||||
return ('confirm which PC drives this machine; retire or move the '
|
||||
'old PC off In Use to hand the machine over')
|
||||
if 'taken over from' in lowered:
|
||||
return ('expected on a PC swap; set the old PC to Retired, '
|
||||
'Inventory or In Repair')
|
||||
if 'relationship type missing' in lowered:
|
||||
return 'run flask seed reference-data, then re-import'
|
||||
return 'review this PC and its machine number'
|
||||
|
||||
# -- ADR-006 collector contract -----------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
@@ -173,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]:
|
||||
@@ -214,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.
|
||||
|
||||
@@ -534,7 +830,8 @@ class ComputersPlugin(BasePlugin):
|
||||
'with POST /api/printers/assignments/seed-from-observed.'
|
||||
% ' and '.join(sent))
|
||||
|
||||
def _sync_machine_link(self, comp, machinenumber, warnings):
|
||||
def _sync_machine_link(self, comp, machinenumber, warnings,
|
||||
origin_label=MACHINE_LINK_ORIGIN, preview=False):
|
||||
"""Link a PC to the machine it drives, from the reported machine number.
|
||||
|
||||
Until this existed the machine number was collected and then discarded,
|
||||
@@ -556,6 +853,15 @@ class ComputersPlugin(BasePlugin):
|
||||
cadence, alerting on every pass. The incumbent therefore keeps the
|
||||
machine while it is still alive, and the challenger is recorded as a
|
||||
dormant link. See _incumbent_has_yielded for what alive means.
|
||||
|
||||
origin_label separates provenance: the collector stamps
|
||||
MACHINE_LINK_ORIGIN, a CSV import stamps MACHINE_LINK_IMPORT_ORIGIN, and
|
||||
each only ever archives its own rows so the two sources coexist.
|
||||
|
||||
preview=True is for a dry-run import: DB writes still happen in the
|
||||
caller's transaction (which it rolls back), but the alert fan-out - the
|
||||
one side effect a rollback cannot undo - is suppressed so a preview
|
||||
never emails anyone.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
@@ -587,7 +893,7 @@ class ComputersPlugin(BasePlugin):
|
||||
mine = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_ORIGIN,
|
||||
AssetRelationship.label == origin_label,
|
||||
).all()
|
||||
|
||||
link = None
|
||||
@@ -597,11 +903,23 @@ class ComputersPlugin(BasePlugin):
|
||||
else:
|
||||
rel.isactive = False
|
||||
|
||||
# The unique constraint on assetrelationships is (source, target, type)
|
||||
# and does NOT include label, so an import (import:machine) and the
|
||||
# collector (collector:machine) cannot each hold their OWN row for the
|
||||
# same PC->machine control. If our label-scoped lookup found nothing but
|
||||
# a row for this exact triple exists under the other provenance, adopt
|
||||
# it rather than insert a duplicate that would violate the constraint.
|
||||
if link is None:
|
||||
link = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
|
||||
# Whoever actively holds this machine now, if it is not this PC.
|
||||
held = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == machine.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_ORIGIN,
|
||||
AssetRelationship.label == origin_label,
|
||||
AssetRelationship.sourceassetid != pcasset.assetid,
|
||||
AssetRelationship.isactive.is_(True),
|
||||
).all()
|
||||
@@ -624,9 +942,10 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
label=origin_label,
|
||||
isactive=False))
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
if not preview:
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
else:
|
||||
link.isactive = False
|
||||
return [{'assetid': machine.assetid,
|
||||
@@ -640,7 +959,7 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
label=origin_label,
|
||||
isactive=True)
|
||||
db.session.add(link)
|
||||
else:
|
||||
@@ -654,7 +973,8 @@ class ComputersPlugin(BasePlugin):
|
||||
warnings.append(
|
||||
'machine {} was taken over from {}; check that PC'.format(
|
||||
machine.assetnumber, oldname))
|
||||
self._alert_pc_superseded(oldname, comp, machine)
|
||||
if not preview:
|
||||
self._alert_pc_superseded(oldname, comp, machine)
|
||||
|
||||
return [{'assetid': machine.assetid,
|
||||
'machinenumber': machine.assetnumber,
|
||||
@@ -757,8 +1077,8 @@ class ComputersPlugin(BasePlugin):
|
||||
#
|
||||
# A row made by hand carries neither label and is deliberately NOT
|
||||
# counted: a person's link is not the collector's to archive.
|
||||
ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN,
|
||||
PARTMARKER_LINK_ORIGIN)
|
||||
ourlabels = (label, MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN,
|
||||
MEASURINGTOOL_LINK_ORIGIN, PARTMARKER_LINK_ORIGIN)
|
||||
others = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == deviceasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
@@ -1310,15 +1630,17 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=adopted.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
if claimed is not None and claimed.label == MACHINE_LINK_ORIGIN:
|
||||
if claimed is not None and claimed.label in (
|
||||
MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN):
|
||||
# LEAVE IT ALONE. On a CMM the instrument IS the reported bay,
|
||||
# so the machine sync has already made the row for this exact
|
||||
# triple - and it finds its own rows BY that label. Relabelling
|
||||
# hid the link from it, so the next cycle built a second row for
|
||||
# the same triple and MySQL rejected it: the PC reported 200,
|
||||
# then 500 forever after. The row's lifecycle (including staying
|
||||
# dormant while a bay is contested) belongs to the machine sync;
|
||||
# adoption only needs the identity, which it has.
|
||||
# so the machine sync (or a CSV import's machine link) has
|
||||
# already made the row for this exact triple - and each finds
|
||||
# its own rows BY that label. Relabelling hid the link from it,
|
||||
# so the next cycle built a second row for the same triple and
|
||||
# MySQL rejected it: the PC reported 200, then 500 forever
|
||||
# after. The row's lifecycle (including staying dormant while a
|
||||
# bay is contested) belongs to the machine sync; adoption only
|
||||
# needs the identity, which it has.
|
||||
pass
|
||||
elif claimed is not None:
|
||||
# Relabelling is what adoption MEANS for any other row: the link
|
||||
|
||||
19
plugins/computers/policyflag.py
Normal file
19
plugins/computers/policyflag.py
Normal 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')
|
||||
1
plugins/computers/services/__init__.py
Normal file
1
plugins/computers/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Computers plugin services: pure policy logic (scope + resolution)."""
|
||||
168
plugins/computers/services/policyresolver.py
Normal file
168
plugins/computers/services/policyresolver.py
Normal 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)
|
||||
96
plugins/computers/services/policyscope.py
Normal file
96
plugins/computers/services/policyscope.py
Normal 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}')
|
||||
Reference in New Issue
Block a user