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