Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.
Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.
NOT READY FOR main. Open items:
- 10 new routes are absent from docs/api-inventory.json, so
test_api_inventory_parity fails. That gate SKIPS on the published mirror
(the test ships, its data file is scrubbed), which is why wilm never saw it.
- dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
- plugins/endpoints/plugin.py carries a whitespace-only re-indent.
Scope: product
478 lines
19 KiB
Python
478 lines
19 KiB
Python
"""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 _is_valid_action(name):
|
|
"""True when an action name is either an allowlisted command or a monitor
|
|
attachment (monitor:<id>) pointing at an existing monitor definition.
|
|
|
|
Monitor actions are intentionally kept out of the command allowlist so they
|
|
are never enqueued as commands; they are only valid as policy attachments.
|
|
"""
|
|
if name in _known_actions():
|
|
return True
|
|
from plugins.endpoints.commands import monitor_id_from_action
|
|
from plugins.endpoints.models import EndpointMonitor
|
|
monitorid = monitor_id_from_action(name)
|
|
if monitorid is None:
|
|
return False
|
|
return db.session.get(EndpointMonitor, monitorid) is not None
|
|
|
|
|
|
def _configpolicy_dict(policy):
|
|
data = policy.to_dict()
|
|
data['actions'] = [a.to_dict() for a in policy.actions]
|
|
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.
|
|
"""
|
|
policy.actions.clear()
|
|
# Flush the deletes before re-appending. The (configurationpolicyid, action)
|
|
# unique constraint collides if SQLAlchemy orders a re-added action's INSERT
|
|
# before the old row's DELETE in the same flush, which surfaces as a generic
|
|
# "value already in use" IntegrityError on an otherwise valid edit.
|
|
db.session.flush()
|
|
for row in actions or []:
|
|
name = (row.get('action') or '').strip()
|
|
if not _is_valid_action(name):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Unknown action: {name!r}')
|
|
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
|
|
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()
|
|
# Flush the deletes before re-appending so the (businesspolicyid,
|
|
# configurationpolicyid) unique constraint does not collide with a re-added
|
|
# item whose INSERT would otherwise race ahead of the old row's DELETE.
|
|
db.session.flush()
|
|
seen = set()
|
|
for order, row in enumerate(items or []):
|
|
cpid = row.get('configurationpolicyid')
|
|
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.
|
|
|
|
Returns command actions tagged kind='command'. Monitor attachments are not
|
|
listed here: the UI builds that branch from the endpoints monitor catalog
|
|
(endpointsApi.monitors) so newly created monitors appear without a reload.
|
|
"""
|
|
gate = _feature_gate()
|
|
if gate:
|
|
return gate
|
|
from plugins.endpoints.commands import COMMAND_ALLOWLIST
|
|
return success_response([
|
|
{'action': name, 'label': meta.get('label'),
|
|
'description': meta.get('description'), 'kind': 'command'}
|
|
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())
|
|
|
|
|
|
@computers_bp.route('/asset/<int:asset_id>/policypanel', methods=['GET'])
|
|
@require_permission('computers.view')
|
|
def policy_panel(asset_id: int):
|
|
"""Keyvalue panel: which policy governs this endpoint, and what it allows.
|
|
|
|
Provenance, not a control. It matters because the effective policy already
|
|
narrows the command allowlist, so it is the answer to "why is Start greyed
|
|
out on this PC" - which is otherwise invisible.
|
|
|
|
Returns an EMPTY field list rather than the feature-gate 404 when the
|
|
feature is off. The panel contract hides a card with no fields; an error
|
|
would render a broken card on every site that has not enabled policies,
|
|
which is most of them.
|
|
"""
|
|
if not policy_flag_enabled():
|
|
return success_response({'fields': []})
|
|
|
|
from ..services.policyresolver import effective_policy_for
|
|
effective = effective_policy_for(asset_id)
|
|
actions = effective.to_dict()
|
|
if not actions:
|
|
return success_response({'fields': []})
|
|
|
|
# One endpoint resolves through one winning business policy, but read the
|
|
# ids off the actions rather than assuming: a fold can draw from more than
|
|
# one configuration policy.
|
|
businesspolicyids = {a['businesspolicyid'] for a in actions.values()
|
|
if a.get('businesspolicyid')}
|
|
configpolicyids = {a['configurationpolicyid'] for a in actions.values()
|
|
if a.get('configurationpolicyid')}
|
|
|
|
names = []
|
|
if businesspolicyids:
|
|
names = [p.name for p in BusinessPolicy.query
|
|
.filter(BusinessPolicy.businesspolicyid.in_(businesspolicyids)).all()]
|
|
|
|
gated = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_GATE)
|
|
desired = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_DESIRED)
|
|
|
|
fields = []
|
|
if names:
|
|
fields.append({'label': 'Policy', 'value': ', '.join(sorted(names))})
|
|
fields.append({'label': 'Configuration policies', 'value': str(len(configpolicyids))})
|
|
fields.append({'label': 'Allowed actions', 'value': str(gated)})
|
|
fields.append({'label': 'Desired state', 'value': str(desired)})
|
|
return success_response({'fields': fields})
|