"""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