"""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:) 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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//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})