Files
shopdb-flask/plugins/computers/services/apppolicy.py
benjamin.baker aa9dafc5ca 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.
2026-09-07 13:27:59 -04:00

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