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:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
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