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:
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user