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.
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""Generate a Configuration Policy from an Application's "Required on all PCs".
|
|
|
|
Ticking that box on an application should not merely flag it in the compliance
|
|
report - it should actually enforce the app onto every PC. This module owns the
|
|
one policy that represents that intent, so the applications API does not have to
|
|
know how policies are shaped.
|
|
|
|
Ownership and the "untouched" rule
|
|
----------------------------------
|
|
A generated policy carries source=POLICY_SOURCE_AUTOAPP, which marks it as ours
|
|
to manage. Unticking the box removes it - but ONLY while it still carries that
|
|
source. If an admin edits the policy through the policy CRUD API it flips to
|
|
'local', and from then on unticking leaves it alone and reports that it was
|
|
kept. Deleting someone's customised enforcement rule because a checkbox changed
|
|
on a different screen would be a genuinely destructive surprise, and the box is
|
|
not a reliable signal that they wanted that.
|
|
|
|
Scope
|
|
-----
|
|
Generated policies use the `allpcs` node, not `true`: "required on all PCs"
|
|
means the PCs people work at, and pushing an installer to a server because it
|
|
appeared in the app catalog is not what the checkbox says. See policyscope.py.
|
|
"""
|
|
|
|
from shopdb.api import db
|
|
|
|
from ..models import (
|
|
ConfigurationPolicy,
|
|
ConfigurationPolicyAction,
|
|
POLICY_SOURCE_AUTOAPP,
|
|
POLICY_SOURCE_LOCAL,
|
|
ACTION_MODE_DESIRED,
|
|
)
|
|
|
|
# The action a generated policy carries. update_app is the allowlisted verb that
|
|
# installs a pinned app package on an endpoint (plugins/endpoints/commands.py),
|
|
# so it is what "this app is required here" reduces to in enforcement terms.
|
|
REQUIRED_APP_ACTION = 'update_app'
|
|
|
|
# Name prefix for generated rows. ConfigurationPolicy.name is unique and NOT
|
|
# NULL, so generated names must be deterministic (we have to find the row again)
|
|
# and distinctive enough not to collide with a hand-authored policy.
|
|
AUTO_POLICY_PREFIX = 'Required app:'
|
|
|
|
|
|
def auto_policy_name(application):
|
|
"""The deterministic policy name for an application."""
|
|
return '{} {}'.format(AUTO_POLICY_PREFIX, application.appname)
|
|
|
|
|
|
def find_auto_policy(application):
|
|
"""Return the policy generated for this app, whoever owns it now.
|
|
|
|
Matches on name, since that is the only durable link - there is no FK from
|
|
a policy back to an application. Returns rows that have since been flipped
|
|
to 'local' too, so callers can tell "customised" from "absent".
|
|
"""
|
|
return ConfigurationPolicy.query.filter_by(
|
|
name=auto_policy_name(application)
|
|
).first()
|
|
|
|
|
|
def sync_required_app_policy(application):
|
|
"""Create or remove the generated policy to match application.isrequired.
|
|
|
|
Returns a short status string for the API to surface to the user:
|
|
'created' - a new policy now enforces this app
|
|
'removed' - the generated policy was deleted
|
|
'kept' - a customised policy was left in place deliberately
|
|
'unchanged' - nothing needed doing
|
|
"""
|
|
existing = find_auto_policy(application)
|
|
|
|
if application.isrequired:
|
|
if existing is not None:
|
|
# Already enforced. Deliberately does NOT rewrite a customised row
|
|
# back to our defaults; re-ticking a box should not silently revert
|
|
# an admin's edits.
|
|
return 'unchanged'
|
|
|
|
policy = ConfigurationPolicy(
|
|
name=auto_policy_name(application),
|
|
description=(
|
|
'Auto-generated from the "Required on all PCs" checkbox on '
|
|
'application "{}". Editing this policy makes it yours: it will '
|
|
'no longer be removed automatically when that box is '
|
|
'unchecked.'.format(application.appname)
|
|
),
|
|
scopejson={'type': 'allpcs'},
|
|
source=POLICY_SOURCE_AUTOAPP,
|
|
)
|
|
db.session.add(policy)
|
|
db.session.flush()
|
|
|
|
db.session.add(ConfigurationPolicyAction(
|
|
configurationpolicyid=policy.configurationpolicyid,
|
|
action=REQUIRED_APP_ACTION,
|
|
mode=ACTION_MODE_DESIRED,
|
|
desiredstate={'installed': True},
|
|
params={'appid': application.appid},
|
|
))
|
|
return 'created'
|
|
|
|
# Not required.
|
|
if existing is None:
|
|
return 'unchanged'
|
|
if existing.source != POLICY_SOURCE_AUTOAPP:
|
|
# Someone customised it. Theirs now - leave it and say so.
|
|
return 'kept'
|
|
|
|
db.session.delete(existing)
|
|
return 'removed'
|
|
|
|
|
|
def disown_if_generated(policy):
|
|
"""Flip a generated policy to 'local' because it is being hand-edited.
|
|
|
|
Called from the policy CRUD write paths. After this, the application
|
|
checkbox no longer owns the row and will not delete it.
|
|
"""
|
|
if policy is not None and policy.source == POLICY_SOURCE_AUTOAPP:
|
|
policy.source = POLICY_SOURCE_LOCAL
|
|
return True
|
|
return False
|