Add application-driven policy generation with an acknowledgement gate

Ticking "Required on all PCs" now generates a configuration policy that
installs the application on every non-server endpoint it matches, rather
than only flagging a software-compliance report row.

Because that save is a fleet-wide deployment, it requires explicit
acknowledgement. The gate is enforced in the API, not only in the form:
a confirmation dialog that can be sidestepped by POSTing directly is
decoration, not a safeguard. It fires only on the transition INTO the
required state, since re-saving an already-required application deploys
nothing new.

Generated policies carry a new 'autoapp' source. They stay editable, and
hand-editing one transfers ownership to 'local' so it survives a later
uncheck; 'shipped' remains read-only. Existing required applications are
not migrated retroactively - policies are only created on save.

Also adds the monitorable fields that the shipped-policy breakup will
build on, and repairs a mangled non-ASCII byte in copilot-instructions.md
that was failing the style gate.
This commit is contained in:
benjamin.baker
2026-09-07 13:27:59 -04:00
parent f73df42110
commit aa9dafc5ca
10 changed files with 497 additions and 7 deletions

View File

@@ -36,6 +36,87 @@ APP_PACKAGE_EXTENSIONS = {'.exe', '.msi', '.msp', '.zip', '.7z', '.cab', '.iso',
'.appx', '.msix', '.ps1', '.bat', '.txt', '.pdf'}
APP_PACKAGE_URL_PREFIX = '/api/applications/package/'
def _requires_policy_ack(app, data):
"""True when this save would newly generate an install-enforcing policy.
Ticking "Required on all PCs" does not just flag a report row: it creates a
policy that installs this application on every matched endpoint. That is a
real change to real machines, so the caller has to acknowledge it
explicitly. Enforced here rather than only in the form because the checkbox
is reachable through the API too, and a confirmation dialog that can be
skipped by calling the endpoint directly is not a safeguard.
Only NEWLY ticking it needs acknowledgement. Unticking, or saving an app
that was already required, does not.
"""
if not data.get('isrequired'):
return False
if app is not None and app.isrequired:
return False
return not data.get('acknowledgepolicy')
POLICY_ACK_MESSAGE = (
'Marking this application as required on all PCs creates a configuration '
'policy that will install it on every non-server endpoint it matches. '
'Confirm to create the policy.'
)
def _validate_monitorable(data, existing=None):
"""Return an error message if monitorable is set without its two fields.
A process monitor needs an image name to watch for and a path to launch when
it is missing; without both, ticking the box would create a monitor that
cannot do anything. Enforced here rather than as a DB constraint so the
columns can stay nullable for rows that predate the feature.
`existing` lets a PATCH-style update validate the MERGED result: a payload
that only toggles the checkbox is still checked against values already
stored on the row.
"""
def merged(field):
if field in data:
return (data.get(field) or '').strip()
return ((getattr(existing, field, None) or '').strip()
if existing is not None else '')
if 'ismonitorable' in data:
monitorable = data.get('ismonitorable')
else:
monitorable = getattr(existing, 'ismonitorable', False)
if not monitorable:
return None
if not merged('monitorlaunchpath'):
return 'A monitorable application needs a launch path'
if not merged('monitorprocessname'):
return 'A monitorable application needs a process name'
return None
def _sync_required_app_policy(app):
"""Sync the auto-generated "required on all PCs" policy, if available.
The policy engine lives in the computers PLUGIN, which may be absent or
disabled, so this is a guarded import: core must not hard-depend on a
plugin. When the plugin is not installed the checkbox keeps its original
compliance-report meaning and nothing else happens.
"""
try:
from plugins.computers.services.apppolicy import sync_required_app_policy
except ImportError:
return None
try:
return sync_required_app_policy(app)
except Exception:
# A policy-generation failure must not lose the user's application edit.
current_app.logger.exception(
'Failed to sync the required-app policy for appid=%s', app.appid)
return None
# A real installer runs to hundreds of megabytes; an ISO runs to gigabytes and
# does not belong in an instance directory. 500MB is the line: enough for the
# msi/exe this is for, small enough that the disk cannot vanish behind it.
@@ -212,6 +293,13 @@ def create_application():
http_code=409
)
monitor_error = _validate_monitorable(data)
if monitor_error:
return error_response(ErrorCodes.VALIDATION_ERROR, monitor_error)
if _requires_policy_ack(None, data):
return error_response(ErrorCodes.VALIDATION_ERROR, POLICY_ACK_MESSAGE)
app = Application(
appname=data['appname'],
appdescription=data.get('appdescription'),
@@ -225,6 +313,9 @@ def create_application():
isprinter=data.get('isprinter', False),
islicenced=data.get('islicenced', False),
isrequired=data.get('isrequired', False),
ismonitorable=data.get('ismonitorable', False),
monitorlaunchpath=data.get('monitorlaunchpath'),
monitorprocessname=data.get('monitorprocessname'),
image=data.get('image')
)
@@ -234,9 +325,15 @@ def create_application():
AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname)
policyresult = _sync_required_app_policy(app)
db.session.commit()
return success_response(app.to_dict(), message='Application created', http_code=201)
payload = app.to_dict()
if policyresult in ('created', 'removed', 'kept'):
payload['policyresult'] = policyresult
return success_response(payload, message='Application created', http_code=201)
@applications_bp.route('/<int:app_id>', methods=['PUT'])
@@ -260,10 +357,18 @@ def update_application(app_id: int):
http_code=409
)
monitor_error = _validate_monitorable(data, existing=app)
if monitor_error:
return error_response(ErrorCodes.VALIDATION_ERROR, monitor_error)
if _requires_policy_ack(app, data):
return error_response(ErrorCodes.VALIDATION_ERROR, POLICY_ACK_MESSAGE)
fields = [
'appname', 'appdescription', 'supportteamid', 'isinstallable',
'applicationnotes', 'installpath', 'applicationlink', 'documentationpath',
'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive'
'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive',
'ismonitorable', 'monitorlaunchpath', 'monitorprocessname'
]
changes = {}
@@ -279,9 +384,20 @@ def update_application(app_id: int):
AuditLog.log('updated', 'Application', entityid=app.appid,
entityname=app.appname, changes=changes)
# Only touch policy when the checkbox itself moved. Syncing on every save
# would let an unrelated edit resurrect a policy an admin had deleted.
policyresult = None
if 'isrequired' in changes:
policyresult = _sync_required_app_policy(app)
apply_import_timestamps(app, data)
db.session.commit()
return success_response(app.to_dict(), message='Application updated')
payload = app.to_dict()
if policyresult in ('created', 'removed', 'kept'):
payload['policyresult'] = policyresult
return success_response(payload, message='Application updated')
@applications_bp.route('/<int:app_id>', methods=['DELETE'])

View File

@@ -33,6 +33,20 @@ class Application(BaseModel):
db.Boolean, default=False,
comment='Required on all PCs (drives the software-compliance report)'
)
# Monitorable: this app can be watched by the endpoint agent as a process
# monitor. A monitor is useless without something to launch and something to
# look for, so both fields below are REQUIRED whenever this is set - enforced
# in the applications API rather than as a DB constraint, because the columns
# must stay nullable for the rows that existed before this shipped.
ismonitorable = db.Column(
db.Boolean, default=False,
comment='Can be watched by the endpoint agent (needs launch path + process name)'
)
# Full path used to START the app when a monitor finds it not running.
monitorlaunchpath = db.Column(db.String(512))
# Windows image name to watch for, e.g. "notepad.exe". This is what the agent
# matches against the running process list.
monitorprocessname = db.Column(db.String(255))
image = db.Column(db.String(255))
# Digest of the app-level uploaded installer (installpath). Lets the
# endpoint update_app push resolve an install source by app alone, with no