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

@@ -19,8 +19,10 @@ from shopdb.api import (db, AuditLog, success_response, error_response,
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
POLICY_SOURCE_AUTOAPP,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.apppolicy import disown_if_generated
from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
@@ -258,7 +260,10 @@ def update_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
# An autoapp row is generated from an Application's "Required on all PCs"
# checkbox and IS editable: editing it is precisely how an admin takes
# ownership. Genuinely external sources (shipped/managed) stay read-only.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
@@ -301,6 +306,10 @@ def update_configuration_policy(policy_id: int):
if err:
return err
# Hand-editing a generated policy transfers ownership: from here on the
# application checkbox will not delete it, it will warn instead.
disown_if_generated(policy)
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
@@ -317,7 +326,9 @@ def delete_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
# As with update: a generated row is the site's to remove, an externally
# managed one is not.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)

View File

@@ -14,6 +14,7 @@ from .policy import (
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
POLICY_SOURCE_SHIPPED,
POLICY_SOURCE_AUTOAPP,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
PolicyActionHold,
@@ -32,6 +33,7 @@ __all__ = [
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'POLICY_SOURCE_SHIPPED',
'POLICY_SOURCE_AUTOAPP',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
'PolicyActionHold',

View File

@@ -33,6 +33,18 @@ POLICY_SOURCE_LOCAL = 'local'
# policies are never confused with what the product shipped.
POLICY_SOURCE_SHIPPED = 'shipped'
# Rows generated FOR US by another surface rather than authored by hand: today
# the "Required on all PCs" checkbox on an Application. Distinct from 'local' so
# unchecking the box knows which policy it owns and may safely remove.
#
# It doubles as the "untouched" test. An autoapp row is one nobody has
# customised, so it is safe to delete when the box is unchecked. The moment an
# admin edits it through the policy CRUD API it becomes 'local' - their work
# now, not ours - and unchecking warns instead of deleting. That keeps the
# generated policy convenient without letting it silently destroy hand-authored
# changes.
POLICY_SOURCE_AUTOAPP = 'autoapp'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.

View 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