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:
124
plugins/computers/services/apppolicy.py
Normal file
124
plugins/computers/services/apppolicy.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user