From 8892ca846be82dc3c240f31f691e8d6a57e75c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Falconnier?= Date: Tue, 26 Nov 2024 16:21:40 +0100 Subject: [PATCH] New strategy to update the voting rules Re-compute all the rules for each update to avoid errors when signing id or binary targets are shared by multiple metabundles or bundles. Also add rule events to ballot box More tests --- tests/santa/test_ballot_box.py | 303 +++++++++++++++++++++++----- zentral/contrib/santa/ballot_box.py | 78 +------ zentral/contrib/santa/utils.py | 169 +++++++++++++++- 3 files changed, 432 insertions(+), 118 deletions(-) diff --git a/tests/santa/test_ballot_box.py b/tests/santa/test_ballot_box.py index 2c4740d47..7960edeb4 100644 --- a/tests/santa/test_ballot_box.py +++ b/tests/santa/test_ballot_box.py @@ -3,7 +3,9 @@ from django.utils.crypto import get_random_string from zentral.contrib.santa.ballot_box import (AnonymousVoter, BallotBox, DuplicateVoteError, ResetNotAllowedError, Voter, VotingError, VotingNotAllowedError) -from zentral.contrib.santa.models import Rule, Target, TargetState +from zentral.contrib.santa.events import SantaBallotEvent, SantaRuleUpdateEvent, SantaTargetStateUpdateEvent +from zentral.contrib.santa.models import Ballot, Rule, Target, TargetState +from zentral.contrib.santa.utils import update_voting_rules from .utils import (add_file_to_test_class, force_ballot, force_configuration, force_enrolled_machine, force_realm_group, force_realm_user, force_target, force_voting_group) @@ -650,6 +652,52 @@ def test_ballot_box_update_target_state_to_partially_allowlisted_to_globally_all self.assertEqual(ts2.score, 3) self.assertEqual(rule_qs.count(), 0) ballot_box2.cast_votes([(configuration, True)]) + ballot = Ballot.objects.get(target=self.file_target, realm_user=realm_user2) + vote = ballot.vote_set.first() + self.assertEqual( + ballot_box2._events, + [(SantaBallotEvent, + {'created_at': ballot.created_at, + 'event_target': None, + 'pk': str(ballot.pk), + 'realm_user': {'pk': str(realm_user2.pk), + 'realm': {'name': realm.name, + 'pk': str(realm.pk)}, + 'username': realm_user2.username}, + 'replaced_by': None, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'user_uid': realm_user2.username, + 'votes': [{'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': vote.created_at, + 'pk': str(vote.pk), + 'was_yes_vote': True, + 'weight': 3}]}), + (SantaTargetStateUpdateEvent, + {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': ts2.created_at, + 'new_value': {'flagged': False, + 'reset_at': None, + 'score': 6, + 'state': 50, + 'state_display': 'PARTIALLY_ALLOWLISTED'}, + 'prev_value': {'flagged': False, + 'reset_at': None, + 'score': 3, + 'state': 0, + 'state_display': 'UNTRUSTED'}, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'updated_at': ts2.updated_at}), + (SantaRuleUpdateEvent, + {'result': 'created', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'ALLOWLIST', + 'primary_users': sorted([realm_user.username, realm_user2.username]), + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}}})] + ) # third vote _, realm_user3 = force_realm_user(realm=realm) ballot_box3 = BallotBox.for_realm_user(self.file_target, realm_user3, all_configurations=True) @@ -662,6 +710,56 @@ def test_ballot_box_update_target_state_to_partially_allowlisted_to_globally_all self.assertEqual(rule.policy, Rule.Policy.ALLOWLIST) self.assertEqual(set(rule.primary_users), {realm_user.username, realm_user2.username}) ballot_box3.cast_votes([(configuration, True)]) + ts3.refresh_from_db() + ballot = Ballot.objects.get(target=self.file_target, realm_user=realm_user3) + vote = ballot.vote_set.first() + self.assertEqual( + ballot_box3._events, + [(SantaBallotEvent, + {'created_at': ballot.created_at, + 'event_target': None, + 'pk': str(ballot.pk), + 'realm_user': {'pk': str(realm_user3.pk), + 'realm': {'name': realm.name, + 'pk': str(realm.pk)}, + 'username': realm_user3.username}, + 'replaced_by': None, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'user_uid': realm_user3.username, + 'votes': [{'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': vote.created_at, + 'pk': str(vote.pk), + 'was_yes_vote': True, + 'weight': 3}]}), + (SantaTargetStateUpdateEvent, + {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': ts3.created_at, + 'new_value': {'flagged': False, + 'reset_at': None, + 'score': 9, + 'state': 50, + 'state_display': 'PARTIALLY_ALLOWLISTED'}, + 'prev_value': {'flagged': False, + 'reset_at': None, + 'score': 6, + 'state': 50, + 'state_display': 'PARTIALLY_ALLOWLISTED'}, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'updated_at': ts3.updated_at}), + (SantaRuleUpdateEvent, + {'result': 'updated', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'ALLOWLIST', + 'primary_users': sorted([realm_user.username, + realm_user2.username, + realm_user3.username,]), + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}}, + 'updates': {'added': {'primary_users': [realm_user3.username]}}})] + ) # fourth vote _, realm_user4 = force_realm_user(realm=realm) ballot_box4 = BallotBox.for_realm_user(self.file_target, realm_user4, all_configurations=True) @@ -673,13 +771,72 @@ def test_ballot_box_update_target_state_to_partially_allowlisted_to_globally_all self.assertEqual(rule, rule2) self.assertEqual(rule2.policy, Rule.Policy.ALLOWLIST) self.assertEqual(set(rule2.primary_users), {realm_user.username, realm_user2.username, realm_user3.username}) + # Inconsistent stuff in the database before last vote + rule.refresh_from_db() + rule.custom_msg = "yolo" + rule.policy = Rule.Policy.BLOCKLIST + rule.save() + # last vote ballot_box4.cast_votes([(configuration, True)]) ts4.refresh_from_db() + ballot = Ballot.objects.get(target=self.file_target, realm_user=realm_user4) + vote = ballot.vote_set.first() + self.assertEqual( + ballot_box4._events, + [(SantaBallotEvent, + {'created_at': ballot.created_at, + 'event_target': None, + 'pk': str(ballot.pk), + 'realm_user': {'pk': str(realm_user4.pk), + 'realm': {'name': realm.name, + 'pk': str(realm.pk)}, + 'username': realm_user4.username}, + 'replaced_by': None, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'user_uid': realm_user4.username, + 'votes': [{'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': vote.created_at, + 'pk': str(vote.pk), + 'was_yes_vote': True, + 'weight': 3}]}), + (SantaTargetStateUpdateEvent, + {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': ts4.created_at, + 'new_value': {'flagged': False, + 'reset_at': None, + 'score': 12, + 'state': 100, + 'state_display': 'GLOBALLY_ALLOWLISTED'}, + 'prev_value': {'flagged': False, + 'reset_at': None, + 'score': 9, + 'state': 50, + 'state_display': 'PARTIALLY_ALLOWLISTED'}, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'updated_at': ts4.updated_at}), + (SantaRuleUpdateEvent, + {'result': 'updated', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'ALLOWLIST', + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}}, + 'updates': {'added': {'custom_msg': '', # Inconsistent state fix + 'policy': Rule.Policy.ALLOWLIST}, # Inconsistent state fix + 'removed': {'custom_msg': 'yolo', # Inconsistent state introduced in test + 'policy': Rule.Policy.BLOCKLIST, # Inconsistent state introduced in test + 'primary_users': sorted([realm_user.username, + realm_user2.username, + realm_user3.username,])}}})] + ) self.assertEqual(ts4.state, TargetState.State.GLOBALLY_ALLOWLISTED) self.assertEqual(ts4.score, 12) self.assertEqual(rule_qs.count(), 1) rule3 = rule_qs.first() self.assertEqual(rule, rule3) + self.assertEqual(rule2.custom_msg, "") self.assertEqual(rule3.policy, Rule.Policy.ALLOWLIST) self.assertEqual(len(rule3.primary_users), 0) @@ -754,31 +911,6 @@ def test_ballot_box_update_target_state_to_banned(self): self.assertEqual(rule.target, self.file_target) self.assertEqual(rule.policy, Rule.Policy.BLOCKLIST) - def test_ballot_box_allowlist_bundle(self): - configuration = force_configuration() - rule_qs = configuration.rule_set.all() - self.assertEqual(rule_qs.count(), 0) - ballot_box = BallotBox.for_realm_user(self.bundle_target, None) - ballot_box._globally_allowlist(configuration) - self.assertEqual(rule_qs.count(), 1) - rule = rule_qs.first() - self.assertEqual(rule.target, self.file_target) - self.assertEqual(rule.policy, Rule.Policy.ALLOWLIST) - self.assertEqual(len(rule.primary_users), 0) - - def test_ballot_box_allowlist_metabundle(self): - configuration = force_configuration() - rule_qs = configuration.rule_set.all() - self.assertEqual(rule_qs.count(), 0) - ballot_box = BallotBox.for_realm_user(self.metabundle_target, None) - ballot_box._globally_allowlist(configuration) - self.assertEqual(rule_qs.count(), 1) - rule = rule_qs.first() - self.assertEqual(rule.target.type, Target.Type.SIGNING_ID) - self.assertEqual(rule.target.identifier, self.file_signing_id) - self.assertEqual(rule.policy, Rule.Policy.ALLOWLIST) - self.assertEqual(len(rule.primary_users), 0) - # target state reset def test_ballot_box_target_state_reset_not_allowed(self): @@ -817,6 +949,32 @@ def test_ballot_box_target_state_reset(self): self.assertIsNone(ts.reset_at) ballot_box.reset_target_state(configuration) ts.refresh_from_db() + self.assertEqual( + ballot_box._events, + [(SantaTargetStateUpdateEvent, + {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'created_at': ts.created_at, + 'new_value': {'flagged': False, + 'reset_at': ts.reset_at, + 'score': 0, + 'state': 0, + 'state_display': 'UNTRUSTED'}, + 'prev_value': {'flagged': True, + 'reset_at': None, + 'score': -100, + 'state': -100, + 'state_display': 'BANNED'}, + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}, + 'updated_at': ts.updated_at}), + (SantaRuleUpdateEvent, + {'result': 'deleted', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'BLOCKLIST', + 'target': {'sha256': self.file_sha256, + 'type': 'BINARY'}}})] + ) self.assertEqual(ts.state, TargetState.State.UNTRUSTED) self.assertFalse(ts.flagged) self.assertEqual(ts.score, 0) @@ -853,32 +1011,81 @@ def test_update_target_states(self): target_state.refresh_from_db() self.assertEqual(target_state.score, 0) - # partially allowlist + # update voting rules - def test_partially_allowlist_rules(self): - realm, realm_user = force_realm_user() - configuration = force_configuration(voting_realm=realm) - force_ballot( - self.file_target, realm_user, - [(configuration, True, configuration.partially_allowlisted_threshold)] + def test_update_voting_rules_remove_cdhash_voting_rule(self): + configuration = force_configuration() + Rule.objects.create( + configuration=configuration, + policy=Rule.Policy.BLOCKLIST, + target=self.cdhash_target, + is_voting_rule=True ) - target_state, _ = TargetState.objects.update_or_create( - target=self.file_target, + self.assertEqual(configuration.rule_set.count(), 1) + event_payloads = list(update_voting_rules([configuration])) + self.assertEqual( + event_payloads, + [{'result': 'deleted', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'BLOCKLIST', + 'target': {'cdhash': self.cdhash, + 'type': 'CDHASH'}}}] + ) + self.assertEqual(configuration.rule_set.count(), 0) + + def test_update_voting_rules_remove_signing_id_voting_rule(self): + configuration = force_configuration() + Rule.objects.create( configuration=configuration, - state=TargetState.State.UNTRUSTED, - reset_at=datetime.utcnow() + policy=Rule.Policy.BLOCKLIST, + target=self.signing_id_target, + is_voting_rule=True + ) + self.assertEqual(configuration.rule_set.count(), 1) + event_payloads = list(update_voting_rules([configuration])) + self.assertEqual( + event_payloads, + [{'result': 'deleted', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'BLOCKLIST', + 'target': {'signing_id': self.file_signing_id, + 'type': 'SIGNINGID'}}}] ) self.assertEqual(configuration.rule_set.count(), 0) - configuration2 = force_configuration(voting_realm=realm) - # second target state in unrelated configurations must not interfere - TargetState.objects.update_or_create( - target=self.file_target, - configuration=configuration2, - score=configuration2.partially_allowlisted_threshold, - state=TargetState.State.PARTIALLY_ALLOWLISTED, + + def test_update_voting_rules_remove_team_id_voting_rule(self): + configuration = force_configuration() + Rule.objects.create( + configuration=configuration, + policy=Rule.Policy.BLOCKLIST, + target=self.team_id_target, + is_voting_rule=True + ) + self.assertEqual(configuration.rule_set.count(), 1) + event_payloads = list(update_voting_rules([configuration])) + self.assertEqual( + event_payloads, + [{'result': 'deleted', + 'rule': {'configuration': {'name': configuration.name, 'pk': configuration.pk}, + 'is_voting_rule': True, + 'policy': 'BLOCKLIST', + 'target': {'team_id': self.file_team_id, + 'type': 'TEAMID'}}}] ) - ballot_box = BallotBox.for_realm_user(self.file_target, realm_user, all_configurations=True) - with self.assertRaises(AssertionError) as cm: - ballot_box._partially_allowlist(configuration) - self.assertEqual(cm.exception.args[0], "No primary users found") self.assertEqual(configuration.rule_set.count(), 0) + + def test_update_voting_rules_keep_non_voting_rule(self): + configuration = force_configuration() + rule = Rule.objects.create( + configuration=configuration, + policy=Rule.Policy.BLOCKLIST, + target=self.team_id_target, + is_voting_rule=False + ) + self.assertEqual(configuration.rule_set.count(), 1) + event_payloads = list(update_voting_rules([configuration])) + self.assertEqual(event_payloads, []) + self.assertEqual(configuration.rule_set.count(), 1) + self.assertEqual(configuration.rule_set.first(), rule) diff --git a/zentral/contrib/santa/ballot_box.py b/zentral/contrib/santa/ballot_box.py index 165acd0ef..7c9f82938 100644 --- a/zentral/contrib/santa/ballot_box.py +++ b/zentral/contrib/santa/ballot_box.py @@ -5,9 +5,9 @@ from django.db.models import Q from django.utils.functional import cached_property from zentral.core.events.base import EventMetadata, EventRequest -from .events import SantaBallotEvent, SantaTargetStateUpdateEvent +from .events import SantaBallotEvent, SantaRuleUpdateEvent, SantaTargetStateUpdateEvent from .models import Ballot, Configuration, EnrolledMachine, Rule, Target, TargetState, Vote, VotingGroup -from .utils import target_related_targets +from .utils import target_related_targets, update_voting_rules logger = logging.getLogger("zentral.contrib.santa.ballot_box") @@ -406,6 +406,7 @@ def cast_votes(self, votes): def _cast_verified_votes(self, votes, event_target=None): self._create_or_update_ballot(votes, event_target) self._update_target_states(votes) + self._update_voting_rules([configuration for configuration, _ in votes]) def _create_or_update_ballot(self, votes, event_target): if not isinstance(votes, set): @@ -464,6 +465,12 @@ def _queue_target_state_update_event(self, pre_update_state, target_state): event_payload["prev_value"] = prev_value self._events.append((SantaTargetStateUpdateEvent, event_payload)) + def _update_voting_rules(self, configurations): + self._events.extend( + (SantaRuleUpdateEvent, payload) + for payload in update_voting_rules(configurations) + ) + def _update_target_state(self, configuration, score, was_yes_vote): target_state = self.target_states[configuration] pre_update_state = target_state.serialize_for_event() @@ -484,22 +491,18 @@ def _update_target_state(self, configuration, score, was_yes_vote): def _update_target_state_state(self, target_state, score): configuration = target_state.configuration if score >= configuration.globally_allowlisted_threshold: - self._globally_allowlist(configuration) if target_state.state != TargetState.State.GLOBALLY_ALLOWLISTED: target_state.state = TargetState.State.GLOBALLY_ALLOWLISTED return True elif score >= configuration.partially_allowlisted_threshold: - self._partially_allowlist(configuration) if target_state.state != TargetState.State.PARTIALLY_ALLOWLISTED: target_state.state = TargetState.State.PARTIALLY_ALLOWLISTED return True elif score <= configuration.banned_threshold: - self._blocklist(configuration) if target_state.state != TargetState.State.BANNED: target_state.state = TargetState.State.BANNED return True else: - self._ensure_no_rules(configuration) if target_state.state != TargetState.State.UNTRUSTED: target_state.state = TargetState.State.UNTRUSTED return True @@ -516,68 +519,7 @@ def reset_target_state(self, configuration): target_state.reset_at = datetime.utcnow() target_state.save() self._queue_target_state_update_event(pre_update_state, target_state) - - # rules - - def _iter_rule_targets(self): - if self.target.type == Target.Type.METABUNDLE: - yield from self.target.metabundle.signing_id_targets.all() - elif self.target.type == Target.Type.BUNDLE: - yield from self.target.bundle.binary_targets.all() - else: - yield self.target - - def _update_or_create_rules(self, configuration, policy, primary_users=None): - Rule.objects.bulk_create( - [ - Rule( - configuration=configuration, - target=target, - policy=policy, - primary_users=primary_users if primary_users else [], - excluded_primary_users=[], - is_voting_rule=True, - ) for target in self._iter_rule_targets() - ], - update_conflicts=True, - unique_fields=["configuration", "target"], - update_fields=["policy", "primary_users", "excluded_primary_users"] - ) - - def _globally_allowlist(self, configuration): - self._update_or_create_rules(configuration, Rule.Policy.ALLOWLIST) - - def _partially_allowlist(self, configuration): - with connection.cursor() as cursor: - cursor.execute( - "select distinct coalesce(u.username, b.user_uid) " - "from santa_ballot b " - "left join realms_realmuser u on (b.realm_user_id = u.uuid) " - "join santa_vote v on (b.id = v.ballot_id) " - "join santa_targetstate ts on (" - " b.target_id = ts.target_id" - " and v.configuration_id = ts.configuration_id" - ") " - "where b.target_id = %s " - "and b.replaced_by_id is null " - "and v.configuration_id = %s " - "and v.was_yes_vote = 't' " - "and (ts.reset_at is null or b.created_at > ts.reset_at)", - [self.target.pk, configuration.pk] - ) - primary_users = list(r[0] for r in cursor.fetchall() if r) - assert len(primary_users) > 0, "No primary users found" - self._update_or_create_rules(configuration, Rule.Policy.ALLOWLIST, primary_users) - - def _blocklist(self, configuration): - self._update_or_create_rules(configuration, Rule.Policy.BLOCKLIST) - - def _ensure_no_rules(self, configuration): - Rule.objects.filter( - configuration=configuration, - target__in=self._iter_rule_targets(), - is_voting_rule=True, - ).delete() + self._update_voting_rules([configuration]) # events diff --git a/zentral/contrib/santa/utils.py b/zentral/contrib/santa/utils.py index b07aee431..3fca38ce3 100644 --- a/zentral/contrib/santa/utils.py +++ b/zentral/contrib/santa/utils.py @@ -2,12 +2,12 @@ import json import plistlib from dateutil import parser -from django.db import connection +from django.db import connection, transaction from django.urls import reverse import psycopg2.extras from zentral.conf import settings from zentral.utils.payloads import generate_payload_uuid, get_payload_identifier, sign_payload -from .models import Target +from .models import Rule, Target def build_santa_enrollment_configuration(enrollment): @@ -446,3 +446,168 @@ def target_related_targets(target): "self": target_type == target.type and target_identifier == target.identifier, } return targets + + +def update_voting_rules(configurations): + """Update the voting rules for multiple configurations + + Applies the target states of multiple configurations, and inserts, updates or deletes the voting rules. + Non-voting rules are left untouched. + """ + query = ( + "with configuration_locks as (" + " select * from santa_configuration" + " where id in %(configuration_ids)s" + " for update" + "), target_states as (" + " select ts.target_id, ts.configuration_id, ts.state, coalesce(u.username, b.user_uid) user" + " from santa_targetstate ts" + " join santa_ballot b on (b.target_id = ts.target_id)" + " join santa_vote v on (v.ballot_id = b.id and v.configuration_id = ts.configuration_id)" + " join realms_realmuser u on (b.realm_user_id = u.uuid)" + " where v.was_yes_vote = 't'" + " and ts.state >= 50 or ts.state <= -100" # only those states will generate rules + " and b.replaced_by_id is null" + " and (ts.reset_at is null or b.created_at > ts.reset_at)" + " and v.configuration_id in %(configuration_ids)s" + "), rule_target_states as (" + # target direct rules + " select ts.target_id, ts.configuration_id, ts.state, ts.user" + " from target_states ts" + " join santa_target t on (ts.target_id = t.id)" + " where t.type in ('CDHASH', 'BINARY', 'SIGNINGID', 'CERTIFICATE', 'TEAMID')" + " union" + # metabundle target → signing id rules + " select mt.target_id, ts.configuration_id, ts.state, ts.user" + " from target_states ts" + " join santa_metabundle m on (m.target_id = ts.target_id)" + " join santa_metabundle_signing_id_targets mt on (mt.metabundle_id = m.id)" + " union" + # bundle target → binary rules + " select bt.target_id, ts.configuration_id, ts.state, ts.user user" + " from target_states ts" + " join santa_bundle b on (b.target_id = ts.target_id)" + " join santa_bundle_binary_targets bt on (bt.bundle_id = b.id)" + "), aggregated_rule_target_states as (" + " select rts.target_id, rts.configuration_id, max(rts.state) state," + " array_agg(distinct rts.user order by rts.user asc) users" + " from rule_target_states rts" + " group by rts.target_id, rts.configuration_id" + "), rules as (" + " select target_id, configuration_id," + # ALLOWLIST or BLOCKLIST + " case when state >= 50 then 1 else 2 end policy," + # primary_users only for PARTIALLY_ALLOWLISTED + " case when state = 50 then users else array[]::text[] end primary_users" + " from aggregated_rule_target_states" + "), inserted as (" + " insert into santa_rule" + ' ("target_id", "configuration_id", "policy",' + ' "primary_users", "excluded_primary_users",' + ' "serial_numbers", "excluded_serial_numbers",' + ' "custom_msg", "description", "is_voting_rule",' + ' "version", "created_at", "updated_at")' + " select target_id, configuration_id, policy," + " primary_users, array[]::text[] excluded_primary_users," + " array[]::text[] serial_numbers, array[]::text[] excluded_serial_numbers," + " '' custom_msg, '' description, TRUE is_voting_rule," + " 1 version, transaction_timestamp() created_at, transaction_timestamp() updated_at" + " from rules" + ' on conflict ("target_id", "configuration_id") do update' + " set policy = excluded.policy," + " primary_users = excluded.primary_users, excluded_primary_users = excluded.excluded_primary_users," + " custom_msg = excluded.custom_msg, version = santa_rule.version + 1," + " updated_at = clock_timestamp()" + " where santa_rule.is_voting_rule = 't' and (" + " excluded.policy != santa_rule.policy" + " or excluded.primary_users != santa_rule.primary_users" + " or excluded.excluded_primary_users != santa_rule.excluded_primary_users" + " or excluded.custom_msg != santa_rule.custom_msg" + " ) returning *" + "), replaced as (" + " select * from santa_rule where id in (select id from inserted)" + "), deleted as (" + " delete from santa_rule where" + " is_voting_rule = 't'" + " and configuration_id in %(configuration_ids)s" + " and not exists (" + " select * from rules r" + " where r.target_id = santa_rule.target_id" + " and r.configuration_id = santa_rule.configuration_id" + " ) returning *" + "), results as (" + " select case when version > 1 then 'updated' else 'created' end _op, * from inserted" + " union" + " select 'replaced' _op, * from replaced" + " union" + " select 'deleted' _op, * from deleted" + ") select r.*," + "t.type target_type, t.identifier target_identifier," + "c.name configuration_name, c.id configuration_pk " + "from results r " + "left join santa_target t on (r.target_id = t.id) " + "left join santa_configuration c on (r.configuration_id = c.id)" + ) + replaced_rules = {} + changed_rules = [] + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute(query, {"configuration_ids": tuple(c.pk for c in configurations)}) + columns = [c.name for c in cursor.description] + for t in cursor.fetchall(): + result = dict(zip(columns, t)) + op = result.pop("_op") + if op == "replaced": + replaced_rules[result["id"]] = result + else: + changed_rules.append((op, result)) + + def result_to_serialized_rule(result): + configuration = {"pk": result.pop("configuration_pk"), + "name": result.pop("configuration_name")} + target = {"type": result.pop("target_type")} + target_identifier = result.pop("target_identifier") + if target["type"] == Target.Type.CDHASH: + target["cdhash"] = target_identifier + elif target["type"] == Target.Type.SIGNING_ID: + target["signing_id"] = target_identifier + elif target["type"] == Target.Type.TEAM_ID: + target["team_id"] = target_identifier + else: + target["sha256"] = target_identifier + sr = { + "configuration": configuration, + "target": target, + "policy": Rule.Policy(result["policy"]).name, + "is_voting_rule": result["is_voting_rule"], + } + if result["primary_users"]: + sr["primary_users"] = sorted(result["primary_users"]) + return sr + + for op, result in changed_rules: + payload = { + "rule": result_to_serialized_rule(result), + "result": op, + } + if op == "updated": + old_result = replaced_rules[result["id"]] + # updates + rule_updates = {} + if old_result["policy"] != result["policy"]: + rule_updates.setdefault("removed", {})["policy"] = Rule.Policy(old_result["policy"]) + rule_updates.setdefault("added", {})["policy"] = Rule.Policy(result["policy"]) + or_pu_s = set(old_result["primary_users"]) + r_pu_s = set(result["primary_users"]) + if or_pu_s != r_pu_s: + rpus = or_pu_s - r_pu_s + if rpus: + rule_updates.setdefault("removed", {})["primary_users"] = sorted(rpus) + apus = r_pu_s - or_pu_s + if apus: + rule_updates.setdefault("added", {})["primary_users"] = sorted(apus) + if old_result["custom_msg"] != result["custom_msg"]: + rule_updates.setdefault("removed", {})["custom_msg"] = old_result["custom_msg"] + rule_updates.setdefault("added", {})["custom_msg"] = result["custom_msg"] + payload["updates"] = rule_updates + yield payload