From 0b3315cef425fd162cc96247240a0411bf9707d2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 08:50:31 +0400 Subject: [PATCH 01/54] add PolicyRule and PolicyViolation models Signed-off-by: tdruez --- policy/migrations/0003_policyrule.py | 35 +++++++++++++++ policy/models.py | 67 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 policy/migrations/0003_policyrule.py diff --git a/policy/migrations/0003_policyrule.py b/policy/migrations/0003_policyrule.py new file mode 100644 index 00000000..ed0e66d1 --- /dev/null +++ b/policy/migrations/0003_policyrule.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.6 on 2026-07-07 04:49 + +import django.db.models.deletion +import dje.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), + ('policy', '0002_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PolicyRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', models.CharField(help_text='Descriptive name for this policy rule.', max_length=100)), + ('rule_type', models.CharField(help_text='The type of evaluation performed by this rule.', max_length=50)), + ('threshold', models.PositiveIntegerField(default=0, help_text='Minimum number of violations required to trigger this rule (0 means any).')), + ('is_active', models.BooleanField(default=True, help_text='Only active rules are evaluated.')), + ('event_name', models.CharField(blank=True, help_text='Notification event to fire when violations are detected.', max_length=100)), + ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + ] diff --git a/policy/models.py b/policy/models.py index cc731855..6cbfbd3a 100755 --- a/policy/models.py +++ b/policy/models.py @@ -244,3 +244,70 @@ def save(self, *args, **kwargs): if self.from_policy.content_type == self.to_policy.content_type: raise AssertionError super().save(*args, **kwargs) + + +class PolicyRuleQuerySet(DataspacedQuerySet): + def active(self): + return self.filter(is_active=True) + + +class PolicyRule(DataspacedModel): + name = models.CharField( + max_length=100, + help_text=_("Descriptive name for this policy rule."), + ) + rule_type = models.CharField( + max_length=50, + help_text=_("The type of evaluation performed by this rule."), + ) + threshold = models.PositiveIntegerField( + default=0, + help_text=_("Minimum number of violations required to trigger this rule (0 means any)."), + ) + is_active = models.BooleanField( + default=True, + help_text=_("Only active rules are evaluated."), + ) + event_name = models.CharField( + max_length=100, + blank=True, + help_text=_("Notification event to fire when violations are detected."), + ) + + objects = PolicyRuleQuerySet.as_manager() + + class Meta: + unique_together = (("dataspace", "uuid"), ("dataspace", "name")) + ordering = ["name"] + + def __str__(self): + return self.name + + +class AbstractPolicyViolation(models.Model): + """Shared fields for all concrete policy violation models. No DB table.""" + + violation_count = models.PositiveIntegerField( + default=0, + help_text=_("Number of objects currently violating the rule."), + ) + detected_date = models.DateTimeField( + auto_now_add=True, + help_text=_("The date and time when this violation was first detected."), + ) + last_checked = models.DateTimeField( + auto_now=True, + help_text=_("The date and time of the last evaluation."), + ) + resolved = models.BooleanField( + default=False, + help_text=_("Indicates whether this violation has been resolved."), + ) + resolved_date = models.DateTimeField( + null=True, + blank=True, + help_text=_("The date and time when this violation was resolved."), + ) + + class Meta: + abstract = True From 8a03e3456747f13aefc58a457689dfa2978f5401 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 08:51:14 +0400 Subject: [PATCH 02/54] add ProductPolicyViolation model Signed-off-by: tdruez --- .../migrations/0018_productpolicyviolation.py | 38 +++++++++++++++++++ product_portfolio/models.py | 32 ++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 product_portfolio/migrations/0018_productpolicyviolation.py diff --git a/product_portfolio/migrations/0018_productpolicyviolation.py b/product_portfolio/migrations/0018_productpolicyviolation.py new file mode 100644 index 00000000..3d7482d4 --- /dev/null +++ b/product_portfolio/migrations/0018_productpolicyviolation.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.6 on 2026-07-07 04:49 + +import django.db.models.deletion +import dje.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), + ('policy', '0003_policyrule'), + ('product_portfolio', '0017_scancodeproject_import_options'), + ] + + operations = [ + migrations.CreateModel( + name='ProductPolicyViolation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('violation_count', models.PositiveIntegerField(default=0, help_text='Number of objects currently violating the rule.')), + ('detected_date', models.DateTimeField(auto_now_add=True, help_text='The date and time when this violation was first detected.')), + ('last_checked', models.DateTimeField(auto_now=True, help_text='The date and time of the last evaluation.')), + ('resolved', models.BooleanField(default=False, help_text='Indicates whether this violation has been resolved.')), + ('resolved_date', models.DateTimeField(blank=True, help_text='The date and time when this violation was resolved.', null=True)), + ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), + ('policy_rule', models.ForeignKey(help_text='The policy rule that triggered this violation.', on_delete=django.db.models.deletion.CASCADE, related_name='product_violations', to='policy.policyrule')), + ('product', models.ForeignKey(help_text='The product in the context of which this violation was detected.', on_delete=django.db.models.deletion.CASCADE, related_name='policy_violations', to='product_portfolio.product')), + ], + options={ + 'ordering': ['-detected_date'], + 'unique_together': {('dataspace', 'uuid'), ('policy_rule', 'product')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + ] diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 9fb8e6c9..df572eec 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -56,6 +56,7 @@ from dje.validators import generic_uri_validator from dje.validators import validate_url_segment from dje.validators import validate_version +from policy.models import AbstractPolicyViolation from vulnerabilities.fetch import fetch_for_packages from vulnerabilities.models import AffectedByVulnerabilityMixin from vulnerabilities.models import AffectedByVulnerabilityRelationship @@ -1901,3 +1902,34 @@ def save(self, *args, **kwargs): "The 'for_package' cannot be the same as 'resolved_to_package'." ) super().save(*args, **kwargs) + + +class ProductPolicyViolationQuerySet(ProductSecuredQuerySet): + def unresolved(self): + return self.filter(resolved=False) + + +class ProductPolicyViolation(DataspacedModel, AbstractPolicyViolation): + """Concrete policy violation scoped to a product.""" + + product = models.ForeignKey( + to="product_portfolio.Product", + on_delete=models.CASCADE, + related_name="policy_violations", + help_text=_("The product in the context of which this violation was detected."), + ) + policy_rule = models.ForeignKey( + to="policy.PolicyRule", + on_delete=models.CASCADE, + related_name="product_violations", + help_text=_("The policy rule that triggered this violation."), + ) + + objects = DataspacedManager.from_queryset(ProductPolicyViolationQuerySet)() + + class Meta: + unique_together = (("dataspace", "uuid"), ("policy_rule", "product")) + ordering = ["-detected_date"] + + def __str__(self): + return f"{self.policy_rule} / {self.product}: {self.violation_count} violation(s)" From 4e6f9d8f2775471e79315b2cadaf936c1713b2d2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 09:05:41 +0400 Subject: [PATCH 03/54] add base class for rules Signed-off-by: tdruez --- policy/rules.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 policy/rules.py diff --git a/policy/rules.py b/policy/rules.py new file mode 100644 index 00000000..0384e213 --- /dev/null +++ b/policy/rules.py @@ -0,0 +1,44 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.apps import apps + + +class BaseRule: + """Base class for policy rule handlers.""" + + rule_type = None + label = None + description = None + + def count_violations(self, policy_rule, product): + """Count objects violating the rule for the given product.""" + raise NotImplementedError + + +class ComplianceAlertRule(BaseRule): + rule_type = "compliance_alert" + label = "Compliance Alert" + description = ( + "Detects packages assigned a usage policy with a compliance alert level of 'error'." + ) + + def count_violations(self, policy_rule, product): + Package = apps.get_model("component_catalog", "package") + + count = Package.objects.filter( + productpackages__product=product, + usage_policy__compliance_alert="error", + ).count() + + return count if count > policy_rule.threshold else 0 + + +RULE_REGISTRY = { + ComplianceAlertRule.rule_type: ComplianceAlertRule(), +} From c38403663531e54696648705c3bd4546083f4c96 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 09:06:02 +0400 Subject: [PATCH 04/54] add admin views for managing rules Signed-off-by: tdruez --- policy/admin.py | 27 +++++++++++++++++++++++++++ policy/forms.py | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/policy/admin.py b/policy/admin.py index 645f3b9e..19d09c4d 100755 --- a/policy/admin.py +++ b/policy/admin.py @@ -23,8 +23,10 @@ from dje.admin import dejacode_site from dje.list_display import AsColored from policy.forms import AssociatedPolicyForm +from policy.forms import PolicyRuleForm from policy.forms import UsagePolicyForm from policy.models import AssociatedPolicy +from policy.models import PolicyRule from policy.models import UsagePolicy License = apps.get_model("license_library", "license") @@ -159,3 +161,28 @@ def download_license_dump_view(self, request): response["Content-Disposition"] = 'attachment; filename="license_policies.yml"' return response + + +@admin.register(PolicyRule, site=dejacode_site) +class PolicyRuleAdmin(DataspacedAdmin): + form = PolicyRuleForm + list_display = ("name", "rule_type", "threshold", "is_active", "event_name", "get_dataspace") + list_filter = DataspacedAdmin.list_filter + ("rule_type", "is_active") + activity_log = False + actions = [] + actions_to_remove = ["copy_to", "compare_with"] + email_notification_on = () + + short_description = ( + "You can define Policy Rules that automatically detect compliance violations " + "across your products and trigger notifications." + ) + + long_description = linebreaksbr( + "A Policy Rule defines a type of automated check to run against your products. " + "When the number of detected issues exceeds the configured threshold, a " + "ProductPolicyViolation is recorded and an optional notification event is fired.\n" + "Set the rule type to match a registered evaluation handler, configure the " + "threshold (0 means any violation triggers the rule), and provide an event name " + "to send a webhook notification when violations are detected or resolved." + ) diff --git a/policy/forms.py b/policy/forms.py index 4049d4b3..562629b1 100644 --- a/policy/forms.py +++ b/policy/forms.py @@ -11,6 +11,8 @@ from dje.forms import ColorCodeFormMixin from dje.forms import DataspacedAdminForm +from policy.models import PolicyRule +from policy.rules import RULE_REGISTRY class UsagePolicyForm(ColorCodeFormMixin, DataspacedAdminForm): @@ -92,3 +94,21 @@ def get_ct(app_label, model): self.add_error("to_policy", msg) return cleaned_data + + +class PolicyRuleForm(DataspacedAdminForm): + class Meta: + model = PolicyRule + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["rule_type"].widget = forms.Select( + choices=[(key, handler.label) for key, handler in RULE_REGISTRY.items()] + ) + + def clean_rule_type(self): + value = self.cleaned_data["rule_type"] + if value not in RULE_REGISTRY: + raise forms.ValidationError(f"Unknown rule type: {value}") + return value From d468a545fadd70c85ec73b3be3a3344b0f09f59f Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 09:10:53 +0400 Subject: [PATCH 05/54] add rules evaluation engine Signed-off-by: tdruez --- policy/engine.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 policy/engine.py diff --git a/policy/engine.py b/policy/engine.py new file mode 100644 index 00000000..8cbac3c9 --- /dev/null +++ b/policy/engine.py @@ -0,0 +1,91 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.utils import timezone + +from notification.models import fire_webhooks +from policy.models import PolicyRule +from policy.rules import RULE_REGISTRY +from product_portfolio.models import ProductPolicyViolation + + +def evaluate_rule(policy_rule, product): + """ + Evaluate a single PolicyRule against a product, create or update the + ProductPolicyViolation record, and fire the notification event on new violations + or resolutions. + Returns the ProductPolicyViolation instance, or None if no violation exists. + """ + rule_handler = RULE_REGISTRY.get(policy_rule.rule_type) + if not rule_handler: + return None + + violation_count = rule_handler.count_violations(policy_rule, product) + + lookup = {"policy_rule": policy_rule, "product": product, "resolved": False} + + if violation_count > 0: + violation, created = ProductPolicyViolation.objects.get_or_create( + **lookup, + defaults={"dataspace": policy_rule.dataspace, "violation_count": violation_count}, + ) + if not created: + violation.violation_count = violation_count + violation.save() + if created and policy_rule.event_name: + fire_violation_event(policy_rule, product, violation_count) + return violation + else: + resolved_count = ProductPolicyViolation.objects.filter(**lookup).update( + resolved=True, + resolved_date=timezone.now(), + ) + if resolved_count and policy_rule.event_name: + fire_resolution_event(policy_rule, product) + return None + + +def evaluate_rules(dataspace, product): + """ + Evaluate all active PolicyRules for the given product. + Returns the list of active ProductPolicyViolation instances. + """ + violations = [] + for policy_rule in PolicyRule.objects.scope(dataspace).active(): + violation = evaluate_rule(policy_rule, product) + if violation: + violations.append(violation) + + return violations + + +def fire_violation_event(policy_rule, product, violation_count): + fire_webhooks( + policy_rule.event_name, + instance=None, + dataspace=policy_rule.dataspace, + payload_override={ + "rule": policy_rule.name, + "rule_type": policy_rule.rule_type, + "violation_count": violation_count, + "product": str(product), + }, + ) + + +def fire_resolution_event(policy_rule, product): + fire_webhooks( + policy_rule.event_name, + instance=None, + dataspace=policy_rule.dataspace, + payload_override={ + "rule": policy_rule.name, + "status": "resolved", + "product": str(product), + }, + ) From 7302e192780cf6af944f682ddd372fdc0fbabfc8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 09:16:27 +0400 Subject: [PATCH 06/54] add tasks for rules evaluation Signed-off-by: tdruez --- policy/engine.py | 4 ++-- policy/tasks.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 policy/tasks.py diff --git a/policy/engine.py b/policy/engine.py index 8cbac3c9..9ad57d89 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -50,13 +50,13 @@ def evaluate_rule(policy_rule, product): return None -def evaluate_rules(dataspace, product): +def evaluate_rules(product): """ Evaluate all active PolicyRules for the given product. Returns the list of active ProductPolicyViolation instances. """ violations = [] - for policy_rule in PolicyRule.objects.scope(dataspace).active(): + for policy_rule in PolicyRule.objects.scope(product.dataspace).active(): violation = evaluate_rule(policy_rule, product) if violation: violations.append(violation) diff --git a/policy/tasks.py b/policy/tasks.py new file mode 100644 index 00000000..ab1b4bbc --- /dev/null +++ b/policy/tasks.py @@ -0,0 +1,35 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.apps import apps + +from django_rq import job + +from policy.engine import evaluate_rules + + +@job +def evaluate_product_rules_task(product_uuid): + """Evaluate all active PolicyRules for the given product UUID.""" + Product = apps.get_model("product_portfolio", "product") + + try: + product = Product.objects.select_related("dataspace").get(uuid=product_uuid) + except Product.DoesNotExist: + return + + evaluate_rules(product) + + +@job +def evaluate_all_products_rules_task(): + """Enqueue evaluate_product_rules_task for every product.""" + Product = apps.get_model("product_portfolio", "product") + + for product in Product.objects.select_related("dataspace").all(): + evaluate_product_rules_task.delay(product_uuid=product.uuid) From b32b73cc14bc82dc1f175c8bcb1d3a91b19b2db7 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 12:13:06 +0400 Subject: [PATCH 07/54] exclude locked products Signed-off-by: tdruez --- policy/tasks.py | 12 ++++++++---- product_portfolio/models.py | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/policy/tasks.py b/policy/tasks.py index ab1b4bbc..b6523449 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -15,7 +15,7 @@ @job def evaluate_product_rules_task(product_uuid): - """Evaluate all active PolicyRules for the given product UUID.""" + """Evaluate all active PolicyRules for the given product.""" Product = apps.get_model("product_portfolio", "product") try: @@ -27,9 +27,13 @@ def evaluate_product_rules_task(product_uuid): @job -def evaluate_all_products_rules_task(): - """Enqueue evaluate_product_rules_task for every product.""" +def evaluate_all_products_rules_task(include_locked=False): + """Enqueue evaluate_product_rules_task for every product, skipping locked ones by default.""" Product = apps.get_model("product_portfolio", "product") - for product in Product.objects.select_related("dataspace").all(): + products = Product.objects.select_related("dataspace") + if not include_locked: + products = products.exclude_locked() + + for product in products: evaluate_product_rules_task.delay(product_uuid=product.uuid) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index df572eec..d6130aaf 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -140,6 +140,9 @@ class Meta(BaseStatusMixin.Meta): class ProductQuerySet(DataspacedQuerySet): + def exclude_locked(self): + return self.exclude(configuration_status__is_locked=True) + def with_risk_threshold(self): return self.annotate( risk_threshold=Coalesce( @@ -287,7 +290,7 @@ def get_queryset( ).scope(user.dataspace) if exclude_locked: - queryset = queryset.exclude(configuration_status__is_locked=True) + queryset = queryset.exclude_locked() if include_inactive: return queryset From 76b0a212010d4385133b289d3240883b1d6b4934 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 18:36:46 +0400 Subject: [PATCH 08/54] add parameters system to the rule model Signed-off-by: tdruez --- policy/admin.py | 11 +++++++++++ policy/migrations/0003_policyrule.py | 1 + policy/models.py | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/policy/admin.py b/policy/admin.py index 19d09c4d..5fba4724 100755 --- a/policy/admin.py +++ b/policy/admin.py @@ -28,6 +28,7 @@ from policy.models import AssociatedPolicy from policy.models import PolicyRule from policy.models import UsagePolicy +from policy.rules import RULE_REGISTRY License = apps.get_model("license_library", "license") @@ -168,6 +169,7 @@ class PolicyRuleAdmin(DataspacedAdmin): form = PolicyRuleForm list_display = ("name", "rule_type", "threshold", "is_active", "event_name", "get_dataspace") list_filter = DataspacedAdmin.list_filter + ("rule_type", "is_active") + readonly_fields = DataspacedAdmin.readonly_fields + ("parameters_schema_hint",) activity_log = False actions = [] actions_to_remove = ["copy_to", "compare_with"] @@ -186,3 +188,12 @@ class PolicyRuleAdmin(DataspacedAdmin): "threshold (0 means any violation triggers the rule), and provide an event name " "to send a webhook notification when violations are detected or resolved." ) + + def parameters_schema_hint(self, obj): + handler = RULE_REGISTRY.get(obj.rule_type) + if not handler or not handler.parameters_schema: + return "No parameters supported for this rule type." + lines = [f"{key}: {desc}" for key, desc in handler.parameters_schema.items()] + return mark_safe("
".join(lines)) + + parameters_schema_hint.short_description = "Supported parameters" diff --git a/policy/migrations/0003_policyrule.py b/policy/migrations/0003_policyrule.py index ed0e66d1..bd663e17 100644 --- a/policy/migrations/0003_policyrule.py +++ b/policy/migrations/0003_policyrule.py @@ -24,6 +24,7 @@ class Migration(migrations.Migration): ('threshold', models.PositiveIntegerField(default=0, help_text='Minimum number of violations required to trigger this rule (0 means any).')), ('is_active', models.BooleanField(default=True, help_text='Only active rules are evaluated.')), ('event_name', models.CharField(blank=True, help_text='Notification event to fire when violations are detected.', max_length=100)), + ('parameters', models.JSONField(blank=True, default=dict, help_text='Optional rule-specific parameters as a JSON object. Supported keys depend on the chosen rule type.')), ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), ], options={ diff --git a/policy/models.py b/policy/models.py index 6cbfbd3a..3215bfb8 100755 --- a/policy/models.py +++ b/policy/models.py @@ -273,6 +273,14 @@ class PolicyRule(DataspacedModel): blank=True, help_text=_("Notification event to fire when violations are detected."), ) + parameters = models.JSONField( + blank=True, + default=dict, + help_text=_( + "Optional rule-specific parameters as a JSON object. " + "Supported keys depend on the chosen rule type." + ), + ) objects = PolicyRuleQuerySet.as_manager() From f55f1ff09815557111b3efccee0e6b01d7d66c04 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Jul 2026 09:19:51 +0400 Subject: [PATCH 09/54] implement multiple rules Signed-off-by: tdruez --- policy/rules.py | 67 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/policy/rules.py b/policy/rules.py index 0384e213..1dd0e4d3 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -15,30 +15,83 @@ class BaseRule: rule_type = None label = None description = None + parameters_schema = {} def count_violations(self, policy_rule, product): """Count objects violating the rule for the given product.""" raise NotImplementedError -class ComplianceAlertRule(BaseRule): - rule_type = "compliance_alert" - label = "Compliance Alert" +class PackageBaseRule(BaseRule): + """Base for rules that count packages matching a fixed filter within a product.""" + + package_filter = {} + + def count_violations(self, policy_rule, product): + Package = apps.get_model("component_catalog", "package") + + count = Package.objects.filter( + productpackages__product=product, + **self.package_filter, + ).count() + + return count if count > policy_rule.threshold else 0 + + +class LicensePolicyErrorRule(PackageBaseRule): + rule_type = "license_policy_error" + label = "License Policy Error" description = ( "Detects packages assigned a usage policy with a compliance alert level of 'error'." ) + package_filter = {"usage_policy__compliance_alert": "error"} + + +class LicensePolicyWarningRule(PackageBaseRule): + rule_type = "license_policy_warning" + label = "License Policy Warning" + description = ( + "Detects packages assigned a usage policy with a compliance alert level of 'warning'." + ) + package_filter = {"usage_policy__compliance_alert": "warning"} + + +class LicenseCoverageGapRule(PackageBaseRule): + rule_type = "license_coverage_gap" + label = "License Coverage Gap" + description = ( + "Detects packages with no license expression, indicating a gap in license coverage." + ) + package_filter = {"license_expression": ""} + + +class VulnerabilityDetectedRule(BaseRule): + rule_type = "vulnerability_detected" + label = "Vulnerability Detected" + description = "Detects packages with at least one known vulnerability (non-null risk score)." + parameters_schema = { + "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.", + } def count_violations(self, policy_rule, product): Package = apps.get_model("component_catalog", "package") - count = Package.objects.filter( + packages = Package.objects.filter( productpackages__product=product, - usage_policy__compliance_alert="error", - ).count() + risk_score__isnull=False, + ) + + min_risk_score = policy_rule.parameters.get("min_risk_score") + if min_risk_score is not None: + packages = packages.filter(risk_score__gte=min_risk_score) + count = packages.count() return count if count > policy_rule.threshold else 0 RULE_REGISTRY = { - ComplianceAlertRule.rule_type: ComplianceAlertRule(), + LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(), + LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(), + LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(), + VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(), } From 80553506d5da859c9427e51ce07376161cca4c75 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Jul 2026 10:07:34 +0400 Subject: [PATCH 10/54] fire event Signed-off-by: tdruez --- policy/admin.py | 5 +++-- policy/engine.py | 12 +++++------- policy/events.py | 20 ++++++++++++++++++++ policy/forms.py | 11 +++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 policy/events.py diff --git a/policy/admin.py b/policy/admin.py index 5fba4724..1150c34a 100755 --- a/policy/admin.py +++ b/policy/admin.py @@ -185,8 +185,9 @@ class PolicyRuleAdmin(DataspacedAdmin): "When the number of detected issues exceeds the configured threshold, a " "ProductPolicyViolation is recorded and an optional notification event is fired.\n" "Set the rule type to match a registered evaluation handler, configure the " - "threshold (0 means any violation triggers the rule), and provide an event name " - "to send a webhook notification when violations are detected or resolved." + "threshold (0 means any violation triggers the rule), and select a notification " + "event to dispatch alerts across all registered channels when violations are " + "detected or resolved." ) def parameters_schema_hint(self, obj): diff --git a/policy/engine.py b/policy/engine.py index 9ad57d89..b9b68677 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -8,7 +8,7 @@ from django.utils import timezone -from notification.models import fire_webhooks +from policy.events import fire_event from policy.models import PolicyRule from policy.rules import RULE_REGISTRY from product_portfolio.models import ProductPolicyViolation @@ -65,11 +65,10 @@ def evaluate_rules(product): def fire_violation_event(policy_rule, product, violation_count): - fire_webhooks( + fire_event( policy_rule.event_name, - instance=None, dataspace=policy_rule.dataspace, - payload_override={ + payload={ "rule": policy_rule.name, "rule_type": policy_rule.rule_type, "violation_count": violation_count, @@ -79,11 +78,10 @@ def fire_violation_event(policy_rule, product, violation_count): def fire_resolution_event(policy_rule, product): - fire_webhooks( + fire_event( policy_rule.event_name, - instance=None, dataspace=policy_rule.dataspace, - payload_override={ + payload={ "rule": policy_rule.name, "status": "resolved", "product": str(product), diff --git a/policy/events.py b/policy/events.py new file mode 100644 index 00000000..d9b2b27e --- /dev/null +++ b/policy/events.py @@ -0,0 +1,20 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from notification.models import fire_webhooks + +POLICY_EVENTS = { + "policy.license_alert": "License-related policy rule violation detected or resolved.", + "policy.security_alert": "Security-related policy rule violation detected or resolved.", + "policy.compliance_alert": "Any policy rule violation detected or resolved.", +} + + +def fire_event(event_name, dataspace, payload): + """Dispatch a policy event to all registered notification channels.""" + fire_webhooks(event_name, instance=None, dataspace=dataspace, payload_override=payload) diff --git a/policy/forms.py b/policy/forms.py index 562629b1..cc960057 100644 --- a/policy/forms.py +++ b/policy/forms.py @@ -8,9 +8,11 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.db.models import BLANK_CHOICE_DASH from dje.forms import ColorCodeFormMixin from dje.forms import DataspacedAdminForm +from policy.events import POLICY_EVENTS from policy.models import PolicyRule from policy.rules import RULE_REGISTRY @@ -106,9 +108,18 @@ def __init__(self, *args, **kwargs): self.fields["rule_type"].widget = forms.Select( choices=[(key, handler.label) for key, handler in RULE_REGISTRY.items()] ) + self.fields["event_name"].widget = forms.Select( + choices=BLANK_CHOICE_DASH + list(POLICY_EVENTS.items()) + ) def clean_rule_type(self): value = self.cleaned_data["rule_type"] if value not in RULE_REGISTRY: raise forms.ValidationError(f"Unknown rule type: {value}") return value + + def clean_event_name(self): + value = self.cleaned_data.get("event_name") + if value and value not in POLICY_EVENTS: + raise forms.ValidationError(f"Unknown event: {value}") + return value From 403838bf1930060e9e4786e8f580e915f2cadfad Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Jul 2026 12:40:09 +0400 Subject: [PATCH 11/54] decouple notification from the rules Signed-off-by: tdruez --- policy/admin.py | 10 ++--- policy/engine.py | 39 ++----------------- policy/events.py | 20 ---------- policy/forms.py | 11 ------ policy/migrations/0003_policyrule.py | 3 +- policy/models.py | 5 --- .../migrations/0018_productpolicyviolation.py | 2 +- 7 files changed, 10 insertions(+), 80 deletions(-) delete mode 100644 policy/events.py diff --git a/policy/admin.py b/policy/admin.py index 1150c34a..ba9d52a3 100755 --- a/policy/admin.py +++ b/policy/admin.py @@ -167,7 +167,7 @@ def download_license_dump_view(self, request): @admin.register(PolicyRule, site=dejacode_site) class PolicyRuleAdmin(DataspacedAdmin): form = PolicyRuleForm - list_display = ("name", "rule_type", "threshold", "is_active", "event_name", "get_dataspace") + list_display = ("name", "rule_type", "threshold", "is_active", "get_dataspace") list_filter = DataspacedAdmin.list_filter + ("rule_type", "is_active") readonly_fields = DataspacedAdmin.readonly_fields + ("parameters_schema_hint",) activity_log = False @@ -183,11 +183,9 @@ class PolicyRuleAdmin(DataspacedAdmin): long_description = linebreaksbr( "A Policy Rule defines a type of automated check to run against your products. " "When the number of detected issues exceeds the configured threshold, a " - "ProductPolicyViolation is recorded and an optional notification event is fired.\n" - "Set the rule type to match a registered evaluation handler, configure the " - "threshold (0 means any violation triggers the rule), and select a notification " - "event to dispatch alerts across all registered channels when violations are " - "detected or resolved." + "ProductPolicyViolation is recorded.\n" + "Set the rule type to match a registered evaluation handler and configure the " + "threshold (0 means any violation triggers the rule). " ) def parameters_schema_hint(self, obj): diff --git a/policy/engine.py b/policy/engine.py index b9b68677..2e623848 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -8,7 +8,6 @@ from django.utils import timezone -from policy.events import fire_event from policy.models import PolicyRule from policy.rules import RULE_REGISTRY from product_portfolio.models import ProductPolicyViolation @@ -17,13 +16,12 @@ def evaluate_rule(policy_rule, product): """ Evaluate a single PolicyRule against a product, create or update the - ProductPolicyViolation record, and fire the notification event on new violations - or resolutions. + ProductPolicyViolation record. Returns the ProductPolicyViolation instance, or None if no violation exists. """ rule_handler = RULE_REGISTRY.get(policy_rule.rule_type) if not rule_handler: - return None + return violation_count = rule_handler.count_violations(policy_rule, product) @@ -37,17 +35,13 @@ def evaluate_rule(policy_rule, product): if not created: violation.violation_count = violation_count violation.save() - if created and policy_rule.event_name: - fire_violation_event(policy_rule, product, violation_count) return violation else: - resolved_count = ProductPolicyViolation.objects.filter(**lookup).update( + ProductPolicyViolation.objects.filter(**lookup).update( resolved=True, resolved_date=timezone.now(), ) - if resolved_count and policy_rule.event_name: - fire_resolution_event(policy_rule, product) - return None + return def evaluate_rules(product): @@ -62,28 +56,3 @@ def evaluate_rules(product): violations.append(violation) return violations - - -def fire_violation_event(policy_rule, product, violation_count): - fire_event( - policy_rule.event_name, - dataspace=policy_rule.dataspace, - payload={ - "rule": policy_rule.name, - "rule_type": policy_rule.rule_type, - "violation_count": violation_count, - "product": str(product), - }, - ) - - -def fire_resolution_event(policy_rule, product): - fire_event( - policy_rule.event_name, - dataspace=policy_rule.dataspace, - payload={ - "rule": policy_rule.name, - "status": "resolved", - "product": str(product), - }, - ) diff --git a/policy/events.py b/policy/events.py deleted file mode 100644 index d9b2b27e..00000000 --- a/policy/events.py +++ /dev/null @@ -1,20 +0,0 @@ -# -# Copyright (c) nexB Inc. and others. All rights reserved. -# DejaCode is a trademark of nexB Inc. -# SPDX-License-Identifier: AGPL-3.0-only -# See https://github.com/aboutcode-org/dejacode for support or download. -# See https://aboutcode.org for more information about AboutCode FOSS projects. -# - -from notification.models import fire_webhooks - -POLICY_EVENTS = { - "policy.license_alert": "License-related policy rule violation detected or resolved.", - "policy.security_alert": "Security-related policy rule violation detected or resolved.", - "policy.compliance_alert": "Any policy rule violation detected or resolved.", -} - - -def fire_event(event_name, dataspace, payload): - """Dispatch a policy event to all registered notification channels.""" - fire_webhooks(event_name, instance=None, dataspace=dataspace, payload_override=payload) diff --git a/policy/forms.py b/policy/forms.py index cc960057..562629b1 100644 --- a/policy/forms.py +++ b/policy/forms.py @@ -8,11 +8,9 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.db.models import BLANK_CHOICE_DASH from dje.forms import ColorCodeFormMixin from dje.forms import DataspacedAdminForm -from policy.events import POLICY_EVENTS from policy.models import PolicyRule from policy.rules import RULE_REGISTRY @@ -108,18 +106,9 @@ def __init__(self, *args, **kwargs): self.fields["rule_type"].widget = forms.Select( choices=[(key, handler.label) for key, handler in RULE_REGISTRY.items()] ) - self.fields["event_name"].widget = forms.Select( - choices=BLANK_CHOICE_DASH + list(POLICY_EVENTS.items()) - ) def clean_rule_type(self): value = self.cleaned_data["rule_type"] if value not in RULE_REGISTRY: raise forms.ValidationError(f"Unknown rule type: {value}") return value - - def clean_event_name(self): - value = self.cleaned_data.get("event_name") - if value and value not in POLICY_EVENTS: - raise forms.ValidationError(f"Unknown event: {value}") - return value diff --git a/policy/migrations/0003_policyrule.py b/policy/migrations/0003_policyrule.py index bd663e17..41558ab5 100644 --- a/policy/migrations/0003_policyrule.py +++ b/policy/migrations/0003_policyrule.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-07-07 04:49 +# Generated by Django 6.0.6 on 2026-07-08 08:39 import django.db.models.deletion import dje.models @@ -23,7 +23,6 @@ class Migration(migrations.Migration): ('rule_type', models.CharField(help_text='The type of evaluation performed by this rule.', max_length=50)), ('threshold', models.PositiveIntegerField(default=0, help_text='Minimum number of violations required to trigger this rule (0 means any).')), ('is_active', models.BooleanField(default=True, help_text='Only active rules are evaluated.')), - ('event_name', models.CharField(blank=True, help_text='Notification event to fire when violations are detected.', max_length=100)), ('parameters', models.JSONField(blank=True, default=dict, help_text='Optional rule-specific parameters as a JSON object. Supported keys depend on the chosen rule type.')), ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), ], diff --git a/policy/models.py b/policy/models.py index 3215bfb8..c3f2870a 100755 --- a/policy/models.py +++ b/policy/models.py @@ -268,11 +268,6 @@ class PolicyRule(DataspacedModel): default=True, help_text=_("Only active rules are evaluated."), ) - event_name = models.CharField( - max_length=100, - blank=True, - help_text=_("Notification event to fire when violations are detected."), - ) parameters = models.JSONField( blank=True, default=dict, diff --git a/product_portfolio/migrations/0018_productpolicyviolation.py b/product_portfolio/migrations/0018_productpolicyviolation.py index 3d7482d4..2558daaa 100644 --- a/product_portfolio/migrations/0018_productpolicyviolation.py +++ b/product_portfolio/migrations/0018_productpolicyviolation.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-07-07 04:49 +# Generated by Django 6.0.6 on 2026-07-08 08:39 import django.db.models.deletion import dje.models From bce3f73b27456ae7ff56d16706c54aab95200987 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Jul 2026 17:38:37 +0400 Subject: [PATCH 12/54] display policy violation in views Signed-off-by: tdruez --- policy/models.py | 11 +++++ product_portfolio/admin.py | 12 +++++- product_portfolio/models.py | 14 +++++++ .../compliance/compliance_dashboard.html | 10 ++++- .../compliance/compliance_panels.html | 40 ++++++++++++++++++- .../compliance/metric_cards.html | 22 ++++++++-- product_portfolio/views.py | 13 ++++++ 7 files changed, 115 insertions(+), 7 deletions(-) diff --git a/policy/models.py b/policy/models.py index c3f2870a..36a433a9 100755 --- a/policy/models.py +++ b/policy/models.py @@ -20,6 +20,7 @@ from dje.models import DataspacedModel from dje.models import DataspacedQuerySet from dje.models import colored_icon_mixin_factory +from policy.rules import RULE_REGISTRY ColoredIconMixin = colored_icon_mixin_factory( verbose_name="usage policy", @@ -286,6 +287,16 @@ class Meta: def __str__(self): return self.name + @property + def rule_label(self): + handler = RULE_REGISTRY.get(self.rule_type) + return handler.label if handler else self.rule_type + + @property + def rule_description(self): + handler = RULE_REGISTRY.get(self.rule_type) + return handler.description if handler else "" + class AbstractPolicyViolation(models.Model): """Shared fields for all concrete policy violation models. No DB table.""" diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py index c9624c07..7e9a9f6f 100644 --- a/product_portfolio/admin.py +++ b/product_portfolio/admin.py @@ -384,7 +384,7 @@ class ProductAdmin( ProductPackageInline, ] form = ProductAdminForm - actions = [] + actions = ["evaluate_policy_rules"] actions_to_remove = ["copy_to", "compare_with", "delete_selected"] navigation_buttons = True activity_log = False @@ -395,6 +395,16 @@ class ProductAdmin( awesomplete_data = {"primary_language": PROGRAMMING_LANGUAGES} readonly_fields = DataspacedAdmin.readonly_fields + ("get_feature_datalist",) + def evaluate_policy_rules(self, request, queryset): + from policy.tasks import evaluate_product_rules_task + + count = queryset.count() + for product in queryset: + evaluate_product_rules_task.delay(product_uuid=product.uuid) + self.message_user(request, f"Policy rules evaluation enqueued for {count} product(s).") + + evaluate_policy_rules.short_description = _("Evaluate policy rules") + def get_feature_datalist(self, obj): if obj.pk: return obj.get_feature_datalist() diff --git a/product_portfolio/models.py b/product_portfolio/models.py index d6130aaf..211f3d32 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -24,6 +24,7 @@ from django.db.models import Max from django.db.models import OuterRef from django.db.models import Q +from django.db.models import Subquery from django.db.models import Value from django.db.models import When from django.db.models.functions import Coalesce @@ -244,6 +245,15 @@ def with_has_vulnerable_packages(self): has_vulnerable_packages=Exists(vulnerable_productpackage_qs), ) + def with_policy_violation_count(self): + subquery = ProductPolicyViolation.objects.filter( + product=OuterRef("pk"), + resolved=False, + ).values("product").annotate(violation_count=models.Count("id")).values("violation_count") + return self.annotate( + policy_violation_count=Subquery(subquery, output_field=models.IntegerField()), + ) + class ProductSecuredManager(DataspacedManager): """ @@ -426,6 +436,10 @@ def save(self, *args, **kwargs): if self.has_changed("configuration_status_id"): self.actions_on_status_change() + from policy.tasks import evaluate_product_rules_task + + evaluate_product_rules_task.delay(product_uuid=self.uuid) + def get_attribution_url(self): return self.get_url("attribution") diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html index bd258e85..80b7375c 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html @@ -130,6 +130,7 @@

{% trans "License compliance" %} {% trans "Security compliance" %} {% trans "Vulnerabilities" %} + {% trans "Policy violations" %} @@ -196,11 +197,18 @@

{% trans "None" %} {% endif %} + + {% if product.policy_violation_count %} + {{ product.policy_violation_count }} + {% else %} + {% trans "None" %} + {% endif %} + {% endwith %} {% empty %} - + {% trans "No active products" %} diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 92c0fe29..0ce2f511 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -214,4 +214,42 @@

{% trans "Security compliance" %}

{% endif %} - \ No newline at end of file + + +{% if policy_violations %} +
+
+
+
+

{% trans "Policy violations" %}

+ + {{ policy_violation_count }} {% trans "active" %} + +
+ + + + + + + + + + + {% for violation in policy_violations %} + + + + + + + {% endfor %} + +
{% trans "Rule" %}{% trans "Description" %}{% trans "Objects in violation" %}{% trans "Detected" %}
+
{{ violation.policy_rule.name }}
+
{{ violation.policy_rule.rule_label }}
+
{{ violation.policy_rule.rule_description }}{{ violation.violation_count }}{{ violation.detected_date|date:"N j, Y" }}
+
+
+
+{% endif %} \ No newline at end of file diff --git a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html index 05a4ecba..1ba1a329 100644 --- a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html +++ b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html @@ -1,6 +1,6 @@ {% load i18n humanize %}
-
+
{% trans "Total packages" %}
{{ total_packages|intcomma }}
@@ -15,7 +15,7 @@
-
+
{% trans "License compliance" %}
@@ -32,7 +32,7 @@
-
+
{% trans "License coverage" %}
@@ -49,7 +49,7 @@
-
+
{% trans "Vulnerabilities" %}
{% if vulnerability_count == 0 %} @@ -75,4 +75,18 @@ {% endif %}
+
+
+
{% trans "Policy violations" %}
+ {% if policy_violation_count == 0 %} +
0
+
{% trans "No active violations" %}
+ {% else %} +
{{ policy_violation_count }}
+
+ {{ policy_violation_count }} {% trans "rule" %}{{ policy_violation_count|pluralize }} {% trans "triggered" %} +
+ {% endif %} +
+
\ No newline at end of file diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f8e50d5f..429b25b7 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2772,6 +2772,7 @@ def get_context_data(self, **kwargs): **self.get_package_compliance_context(productpackages), **self.get_license_compliance_context(licenses), **self.get_security_compliance_context(product), + **self.get_policy_compliance_context(product), } ) @@ -2842,6 +2843,17 @@ def get_license_compliance_context(licenses, distribution_limit=10): "remaining_license_count": max(0, len(license_distribution) - distribution_limit), } + @staticmethod + @staticmethod + def get_policy_compliance_context(product): + policy_violations = ( + product.policy_violations.filter(resolved=False).select_related("policy_rule") + ) + return { + "policy_violations": policy_violations, + "policy_violation_count": policy_violations.count(), + } + @staticmethod def get_security_compliance_context(product, display_limit=10): risk_threshold = product.get_vulnerabilities_risk_threshold() @@ -3057,6 +3069,7 @@ def get_queryset(self): .with_compliance_data() .with_max_risk_level() .with_has_vulnerable_packages() + .with_policy_violation_count() ) def get_context_data(self, **kwargs): From ce7ffe4d9c2c2a188b10134ec521ef2ef8e3c039 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Jul 2026 17:51:01 +0400 Subject: [PATCH 13/54] add link from compliance view Signed-off-by: tdruez --- .../compliance/compliance_dashboard.html | 20 ++++--------------- product_portfolio/views.py | 3 +-- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html index 80b7375c..3da15223 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html @@ -128,7 +128,6 @@

{% trans "Product" %} {% trans "Packages" %} {% trans "License compliance" %} - {% trans "Security compliance" %} {% trans "Vulnerabilities" %} {% trans "Policy violations" %} @@ -162,19 +161,6 @@

{% trans "OK" %} {% endif %} - - {% if product.max_risk_level == "critical" %} - {% trans "Critical" %} - {% elif product.max_risk_level == "high" %} - {% trans "High" %} - {% elif product.max_risk_level == "medium" %} - {% trans "Medium" %} - {% elif product.max_risk_level == "low" %} - {% trans "Low" %} - {% else %} - {% trans "OK" %} - {% endif %} - {% if product.risk_threshold and product.vulnerability_count %} @@ -199,7 +185,9 @@

{% if product.policy_violation_count %} - {{ product.policy_violation_count }} + + {{ product.policy_violation_count }} {% trans "rule" %}{{ product.policy_violation_count|pluralize }} {% trans "triggered" %} + {% else %} {% trans "None" %} {% endif %} @@ -208,7 +196,7 @@

{% endwith %} {% empty %} - + {% trans "No active products" %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 429b25b7..7ea08b81 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -3054,20 +3054,19 @@ class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, Dataspa "package_count": "Packages", "license_error_count": "License errors", "license_warning_count": "License warnings", - "max_risk_level": "Max risk level", "risk_threshold": "Risk threshold", "critical_count": "Critical", "high_count": "High", "medium_count": "Medium", "low_count": "Low", "vulnerability_count": "Total vulnerabilities", + "policy_violation_count": "Policy violations", } def get_queryset(self): return ( get_viewable_products(self.request.user) .with_compliance_data() - .with_max_risk_level() .with_has_vulnerable_packages() .with_policy_violation_count() ) From 1dee081e150e91c458aa56e842353d81e7cef5af Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Jul 2026 19:02:33 +0400 Subject: [PATCH 14/54] refine the dashboard view Signed-off-by: tdruez --- product_portfolio/filters.py | 15 +++++++ .../compliance/compliance_dashboard.html | 40 +++++++++++-------- .../compliance/compliance_panels.html | 2 +- product_portfolio/views.py | 14 +++++-- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 98f6f369..846d6d8b 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -36,6 +36,7 @@ from product_portfolio.models import ProductComponent from product_portfolio.models import ProductDependency from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPolicyViolation from product_portfolio.models import ProductStatus from vulnerabilities.filters import ScoreRangeFilter from vulnerabilities.models import RISK_SCORE_RANGES @@ -149,6 +150,10 @@ class ProductFilterSet(DataspacedFilterSet): label=_("License issues"), method="filter_license_compliance_issues", ) + policy_violations = django_filters.BooleanFilter( + label=_("Policy violations"), + method="filter_policy_violations", + ) class Meta: model = Product @@ -189,6 +194,16 @@ def filter_license_compliance_issues(self, queryset, name, value): condition = Exists(has_alert) return queryset.filter(condition if value else ~condition) + def filter_policy_violations(self, queryset, name, value): + if value is None: + return queryset + has_violation = ProductPolicyViolation.objects.filter( + product_id=OuterRef("pk"), + resolved=False, + ) + condition = Exists(has_violation) + return queryset.filter(condition if value else ~condition) + class BaseProductRelationFilterSet(DataspacedFilterSet): field_name_prefix = None diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html index 3da15223..77a99961 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html @@ -68,7 +68,7 @@

-
-
-
{% trans "Security issues" %}
-
- {{ products_with_critical_or_high }} -
-
- {% if products_with_critical_or_high %} - {% trans "products with critical/high vulnerabilities" %} - {% else %} - {% trans "No critical or high vulnerabilities" %} - {% endif %} -
-
-
{% trans "Total vulnerabilities" %}
@@ -119,6 +104,27 @@

+
+
+
{% trans "Policy violations" %}
+ {% if products_with_policy_violations %} +
+ {{ products_with_policy_violations }} + + {% else %} +
+ {{ products_with_policy_violations }} +
+ {% endif %} +
+ {% if products_with_policy_violations %} + {% trans "products with active rule violations" %} + {% else %} + {% trans "No active policy violations" %} + {% endif %} +
+
+
diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 0ce2f511..bcf5c2cb 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -217,7 +217,7 @@

{% trans "Security compliance" %}

{% if policy_violations %} -
+
diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 7ea08b81..80d3688d 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -3075,14 +3075,20 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) products = self.object_list - products_with_issues = products.with_compliance_issues().count() + products_with_issues = products.filter( + Q(license_error_count__gt=0) + | Q(license_warning_count__gt=0) + | Q(critical_count__gt=0) + | Q(high_count__gt=0) + | Q(policy_violation_count__gt=0) + ).count() products_with_license_issues = products.filter( Q(license_error_count__gt=0) | Q(license_warning_count__gt=0) ).count() - products_with_critical_or_high = products.filter( - Q(critical_count__gt=0) | Q(high_count__gt=0) + products_with_policy_violations = products.filter( + policy_violation_count__gt=0 ).count() totals = products.aggregate( @@ -3098,7 +3104,7 @@ def get_context_data(self, **kwargs): "total_products": context["paginator"].count, "products_with_issues": products_with_issues, "products_with_license_issues": products_with_license_issues, - "products_with_critical_or_high": products_with_critical_or_high, + "products_with_policy_violations": products_with_policy_violations, "total_vulnerabilities": totals["total_vulnerabilities"] or 0, "total_critical": totals["total_critical"] or 0, "total_high": totals["total_high"] or 0, From 81b2dcb87dd33d848d880806bfc575608930615e Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 14 Jul 2026 16:56:07 +0400 Subject: [PATCH 15/54] add link to policy table Signed-off-by: tdruez --- .../templates/product_portfolio/compliance/metric_cards.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html index 1ba1a329..91c6db46 100644 --- a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html +++ b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html @@ -84,7 +84,9 @@ {% else %}
{{ policy_violation_count }}
- {{ policy_violation_count }} {% trans "rule" %}{{ policy_violation_count|pluralize }} {% trans "triggered" %} + + {{ policy_violation_count }} {% trans "rule" %}{{ policy_violation_count|pluralize }} {% trans "triggered" %} +
{% endif %}
From 565857953d3b5fac4f9c6738c57c69c44ec2ccd3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 12:32:30 +0400 Subject: [PATCH 16/54] move the rule configuration to the dataspace Signed-off-by: tdruez --- dje/admin.py | 6 ++- ...aspaceconfiguration_policy_rules_config.py | 18 +++++++ dje/models.py | 8 +++ policy/admin.py | 37 ------------- policy/engine.py | 46 +++++++++++----- policy/forms.py | 20 ------- policy/migrations/0003_policyrule.py | 35 ------------- policy/models.py | 52 ------------------- policy/rules.py | 12 ++--- .../migrations/0018_productpolicyviolation.py | 9 ++-- product_portfolio/models.py | 39 ++++++++++---- .../compliance/compliance_panels.html | 7 +-- product_portfolio/views.py | 9 ++-- 13 files changed, 106 insertions(+), 192 deletions(-) create mode 100644 dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py delete mode 100644 policy/migrations/0003_policyrule.py diff --git a/dje/admin.py b/dje/admin.py index 6aa131b5..53563ba8 100644 --- a/dje/admin.py +++ b/dje/admin.py @@ -1141,7 +1141,11 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline): ), ] # Do not include the Dataspace related FKs on addition as the Dataspace does not exist yet - fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets + policy_rules_fieldset = ( + "Policy Rules", + {"fields": ("policy_rules_config",)}, + ) + fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets + [policy_rules_fieldset] can_delete = False def get_fieldsets(self, request, obj=None): diff --git a/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py b/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py new file mode 100644 index 00000000..8d77f9e8 --- /dev/null +++ b/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-15 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='dataspaceconfiguration', + name='policy_rules_config', + field=models.JSONField(blank=True, default=dict, help_text='Override default policy rule settings for this dataspace. '), + ), + ] diff --git a/dje/models.py b/dje/models.py index 2d87316d..e263ceda 100644 --- a/dje/models.py +++ b/dje/models.py @@ -614,6 +614,14 @@ class DataspaceConfiguration(DataspaceForeignKeyValidationMixin, models.Model): ), ) + policy_rules_config = models.JSONField( + blank=True, + default=dict, + help_text=_( + "Override default policy rule settings for this dataspace. " + ), + ) + def __str__(self): return f"{self.dataspace}" diff --git a/policy/admin.py b/policy/admin.py index ba9d52a3..645f3b9e 100755 --- a/policy/admin.py +++ b/policy/admin.py @@ -23,12 +23,9 @@ from dje.admin import dejacode_site from dje.list_display import AsColored from policy.forms import AssociatedPolicyForm -from policy.forms import PolicyRuleForm from policy.forms import UsagePolicyForm from policy.models import AssociatedPolicy -from policy.models import PolicyRule from policy.models import UsagePolicy -from policy.rules import RULE_REGISTRY License = apps.get_model("license_library", "license") @@ -162,37 +159,3 @@ def download_license_dump_view(self, request): response["Content-Disposition"] = 'attachment; filename="license_policies.yml"' return response - - -@admin.register(PolicyRule, site=dejacode_site) -class PolicyRuleAdmin(DataspacedAdmin): - form = PolicyRuleForm - list_display = ("name", "rule_type", "threshold", "is_active", "get_dataspace") - list_filter = DataspacedAdmin.list_filter + ("rule_type", "is_active") - readonly_fields = DataspacedAdmin.readonly_fields + ("parameters_schema_hint",) - activity_log = False - actions = [] - actions_to_remove = ["copy_to", "compare_with"] - email_notification_on = () - - short_description = ( - "You can define Policy Rules that automatically detect compliance violations " - "across your products and trigger notifications." - ) - - long_description = linebreaksbr( - "A Policy Rule defines a type of automated check to run against your products. " - "When the number of detected issues exceeds the configured threshold, a " - "ProductPolicyViolation is recorded.\n" - "Set the rule type to match a registered evaluation handler and configure the " - "threshold (0 means any violation triggers the rule). " - ) - - def parameters_schema_hint(self, obj): - handler = RULE_REGISTRY.get(obj.rule_type) - if not handler or not handler.parameters_schema: - return "No parameters supported for this rule type." - lines = [f"{key}: {desc}" for key, desc in handler.parameters_schema.items()] - return mark_safe("
".join(lines)) - - parameters_schema_hint.short_description = "Supported parameters" diff --git a/policy/engine.py b/policy/engine.py index 2e623848..bf9ea716 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -8,34 +8,47 @@ from django.utils import timezone -from policy.models import PolicyRule from policy.rules import RULE_REGISTRY from product_portfolio.models import ProductPolicyViolation -def evaluate_rule(policy_rule, product): +def get_effective_config(rule_type, dataspace): """ - Evaluate a single PolicyRule against a product, create or update the - ProductPolicyViolation record. - Returns the ProductPolicyViolation instance, or None if no violation exists. + Resolve threshold, parameters, and is_active for a rule type in a given dataspace. + + Reads the dataspace-level override from DataspaceConfiguration.policy_rules_config, + falling back to the code defaults defined on the rule handler. """ - rule_handler = RULE_REGISTRY.get(policy_rule.rule_type) - if not rule_handler: - return + handler = RULE_REGISTRY[rule_type] + try: + rule_config = dataspace.configuration.policy_rules_config.get(rule_type, {}) + except AttributeError: + rule_config = {} + + return { + "is_active": rule_config.get("is_active", True), + "threshold": rule_config.get("threshold", handler.default_threshold), + "parameters": rule_config.get("parameters", {}), + } + - violation_count = rule_handler.count_violations(policy_rule, product) +def evaluate_rule(rule_type, product, threshold, parameters): + """Evaluate a single rule against a product and record the violation if triggered.""" + handler = RULE_REGISTRY[rule_type] + violation_count = handler.count_violations(product, threshold, parameters) - lookup = {"policy_rule": policy_rule, "product": product, "resolved": False} + lookup = {"rule_type": rule_type, "product": product, "resolved": False} if violation_count > 0: violation, created = ProductPolicyViolation.objects.get_or_create( **lookup, - defaults={"dataspace": policy_rule.dataspace, "violation_count": violation_count}, + defaults={"dataspace": product.dataspace, "violation_count": violation_count}, ) if not created: violation.violation_count = violation_count violation.save() return violation + else: ProductPolicyViolation.objects.filter(**lookup).update( resolved=True, @@ -46,12 +59,17 @@ def evaluate_rule(policy_rule, product): def evaluate_rules(product): """ - Evaluate all active PolicyRules for the given product. + Evaluate all rules in RULE_REGISTRY for the given product. + Returns the list of active ProductPolicyViolation instances. """ violations = [] - for policy_rule in PolicyRule.objects.scope(product.dataspace).active(): - violation = evaluate_rule(policy_rule, product) + for rule_type in RULE_REGISTRY: + config = get_effective_config(rule_type, product.dataspace) + if not config["is_active"]: + continue + + violation = evaluate_rule(rule_type, product, config["threshold"], config["parameters"]) if violation: violations.append(violation) diff --git a/policy/forms.py b/policy/forms.py index 562629b1..4049d4b3 100644 --- a/policy/forms.py +++ b/policy/forms.py @@ -11,8 +11,6 @@ from dje.forms import ColorCodeFormMixin from dje.forms import DataspacedAdminForm -from policy.models import PolicyRule -from policy.rules import RULE_REGISTRY class UsagePolicyForm(ColorCodeFormMixin, DataspacedAdminForm): @@ -94,21 +92,3 @@ def get_ct(app_label, model): self.add_error("to_policy", msg) return cleaned_data - - -class PolicyRuleForm(DataspacedAdminForm): - class Meta: - model = PolicyRule - fields = "__all__" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["rule_type"].widget = forms.Select( - choices=[(key, handler.label) for key, handler in RULE_REGISTRY.items()] - ) - - def clean_rule_type(self): - value = self.cleaned_data["rule_type"] - if value not in RULE_REGISTRY: - raise forms.ValidationError(f"Unknown rule type: {value}") - return value diff --git a/policy/migrations/0003_policyrule.py b/policy/migrations/0003_policyrule.py deleted file mode 100644 index 41558ab5..00000000 --- a/policy/migrations/0003_policyrule.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 6.0.6 on 2026-07-08 08:39 - -import django.db.models.deletion -import dje.models -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), - ('policy', '0002_initial'), - ] - - operations = [ - migrations.CreateModel( - name='PolicyRule', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), - ('name', models.CharField(help_text='Descriptive name for this policy rule.', max_length=100)), - ('rule_type', models.CharField(help_text='The type of evaluation performed by this rule.', max_length=50)), - ('threshold', models.PositiveIntegerField(default=0, help_text='Minimum number of violations required to trigger this rule (0 means any).')), - ('is_active', models.BooleanField(default=True, help_text='Only active rules are evaluated.')), - ('parameters', models.JSONField(blank=True, default=dict, help_text='Optional rule-specific parameters as a JSON object. Supported keys depend on the chosen rule type.')), - ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), - ], - options={ - 'ordering': ['name'], - 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, - }, - bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), - ), - ] diff --git a/policy/models.py b/policy/models.py index 36a433a9..ed649d0d 100755 --- a/policy/models.py +++ b/policy/models.py @@ -20,7 +20,6 @@ from dje.models import DataspacedModel from dje.models import DataspacedQuerySet from dje.models import colored_icon_mixin_factory -from policy.rules import RULE_REGISTRY ColoredIconMixin = colored_icon_mixin_factory( verbose_name="usage policy", @@ -247,57 +246,6 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class PolicyRuleQuerySet(DataspacedQuerySet): - def active(self): - return self.filter(is_active=True) - - -class PolicyRule(DataspacedModel): - name = models.CharField( - max_length=100, - help_text=_("Descriptive name for this policy rule."), - ) - rule_type = models.CharField( - max_length=50, - help_text=_("The type of evaluation performed by this rule."), - ) - threshold = models.PositiveIntegerField( - default=0, - help_text=_("Minimum number of violations required to trigger this rule (0 means any)."), - ) - is_active = models.BooleanField( - default=True, - help_text=_("Only active rules are evaluated."), - ) - parameters = models.JSONField( - blank=True, - default=dict, - help_text=_( - "Optional rule-specific parameters as a JSON object. " - "Supported keys depend on the chosen rule type." - ), - ) - - objects = PolicyRuleQuerySet.as_manager() - - class Meta: - unique_together = (("dataspace", "uuid"), ("dataspace", "name")) - ordering = ["name"] - - def __str__(self): - return self.name - - @property - def rule_label(self): - handler = RULE_REGISTRY.get(self.rule_type) - return handler.label if handler else self.rule_type - - @property - def rule_description(self): - handler = RULE_REGISTRY.get(self.rule_type) - return handler.description if handler else "" - - class AbstractPolicyViolation(models.Model): """Shared fields for all concrete policy violation models. No DB table.""" diff --git a/policy/rules.py b/policy/rules.py index 1dd0e4d3..748e5d15 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -17,7 +17,7 @@ class BaseRule: description = None parameters_schema = {} - def count_violations(self, policy_rule, product): + def count_violations(self, product, threshold, parameters): """Count objects violating the rule for the given product.""" raise NotImplementedError @@ -27,7 +27,7 @@ class PackageBaseRule(BaseRule): package_filter = {} - def count_violations(self, policy_rule, product): + def count_violations(self, product, threshold, parameters): Package = apps.get_model("component_catalog", "package") count = Package.objects.filter( @@ -35,7 +35,7 @@ def count_violations(self, policy_rule, product): **self.package_filter, ).count() - return count if count > policy_rule.threshold else 0 + return count if count > threshold else 0 class LicensePolicyErrorRule(PackageBaseRule): @@ -73,7 +73,7 @@ class VulnerabilityDetectedRule(BaseRule): "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.", } - def count_violations(self, policy_rule, product): + def count_violations(self, product, threshold, parameters): Package = apps.get_model("component_catalog", "package") packages = Package.objects.filter( @@ -81,12 +81,12 @@ def count_violations(self, policy_rule, product): risk_score__isnull=False, ) - min_risk_score = policy_rule.parameters.get("min_risk_score") + min_risk_score = parameters.get("min_risk_score") if min_risk_score is not None: packages = packages.filter(risk_score__gte=min_risk_score) count = packages.count() - return count if count > policy_rule.threshold else 0 + return count if count > threshold else 0 RULE_REGISTRY = { diff --git a/product_portfolio/migrations/0018_productpolicyviolation.py b/product_portfolio/migrations/0018_productpolicyviolation.py index 2558daaa..9b38d420 100644 --- a/product_portfolio/migrations/0018_productpolicyviolation.py +++ b/product_portfolio/migrations/0018_productpolicyviolation.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-07-08 08:39 +# Generated by Django 6.0.6 on 2026-07-15 08:25 import django.db.models.deletion import dje.models @@ -9,8 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), - ('policy', '0003_policyrule'), + ('dje', '0016_dataspaceconfiguration_policy_rules_config'), ('product_portfolio', '0017_scancodeproject_import_options'), ] @@ -25,13 +24,13 @@ class Migration(migrations.Migration): ('last_checked', models.DateTimeField(auto_now=True, help_text='The date and time of the last evaluation.')), ('resolved', models.BooleanField(default=False, help_text='Indicates whether this violation has been resolved.')), ('resolved_date', models.DateTimeField(blank=True, help_text='The date and time when this violation was resolved.', null=True)), + ('rule_type', models.CharField(help_text='The rule type from the rule registry that triggered this violation.', max_length=50)), ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), - ('policy_rule', models.ForeignKey(help_text='The policy rule that triggered this violation.', on_delete=django.db.models.deletion.CASCADE, related_name='product_violations', to='policy.policyrule')), ('product', models.ForeignKey(help_text='The product in the context of which this violation was detected.', on_delete=django.db.models.deletion.CASCADE, related_name='policy_violations', to='product_portfolio.product')), ], options={ 'ordering': ['-detected_date'], - 'unique_together': {('dataspace', 'uuid'), ('policy_rule', 'product')}, + 'unique_together': {('dataspace', 'uuid'), ('rule_type', 'product')}, }, bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), ), diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 211f3d32..6a94d2e2 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -246,10 +246,15 @@ def with_has_vulnerable_packages(self): ) def with_policy_violation_count(self): - subquery = ProductPolicyViolation.objects.filter( - product=OuterRef("pk"), - resolved=False, - ).values("product").annotate(violation_count=models.Count("id")).values("violation_count") + subquery = ( + ProductPolicyViolation.objects.filter( + product=OuterRef("pk"), + resolved=False, + ) + .values("product") + .annotate(violation_count=models.Count("id")) + .values("violation_count") + ) return self.annotate( policy_violation_count=Subquery(subquery, output_field=models.IntegerField()), ) @@ -1935,18 +1940,30 @@ class ProductPolicyViolation(DataspacedModel, AbstractPolicyViolation): related_name="policy_violations", help_text=_("The product in the context of which this violation was detected."), ) - policy_rule = models.ForeignKey( - to="policy.PolicyRule", - on_delete=models.CASCADE, - related_name="product_violations", - help_text=_("The policy rule that triggered this violation."), + rule_type = models.CharField( + max_length=50, + help_text=_("The rule type from the rule registry that triggered this violation."), ) objects = DataspacedManager.from_queryset(ProductPolicyViolationQuerySet)() class Meta: - unique_together = (("dataspace", "uuid"), ("policy_rule", "product")) + unique_together = (("dataspace", "uuid"), ("rule_type", "product")) ordering = ["-detected_date"] def __str__(self): - return f"{self.policy_rule} / {self.product}: {self.violation_count} violation(s)" + return f"{self.rule_type} / {self.product}: {self.violation_count} violation(s)" + + @property + def rule_label(self): + from policy.rules import RULE_REGISTRY + + handler = RULE_REGISTRY.get(self.rule_type) + return handler.label if handler else self.rule_type + + @property + def rule_description(self): + from policy.rules import RULE_REGISTRY + + handler = RULE_REGISTRY.get(self.rule_type) + return handler.description if handler else "" diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index bcf5c2cb..b4f5a5cc 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -238,11 +238,8 @@

{% trans "Policy violations" %}

{% for violation in policy_violations %} - -
{{ violation.policy_rule.name }}
-
{{ violation.policy_rule.rule_label }}
- - {{ violation.policy_rule.rule_description }} + {{ violation.rule_label }} + {{ violation.rule_description }} {{ violation.violation_count }} {{ violation.detected_date|date:"N j, Y" }} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 80d3688d..0083f70d 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2843,11 +2843,10 @@ def get_license_compliance_context(licenses, distribution_limit=10): "remaining_license_count": max(0, len(license_distribution) - distribution_limit), } - @staticmethod @staticmethod def get_policy_compliance_context(product): - policy_violations = ( - product.policy_violations.filter(resolved=False).select_related("policy_rule") + policy_violations = product.policy_violations.filter(resolved=False).select_related( + "policy_rule" ) return { "policy_violations": policy_violations, @@ -3087,9 +3086,7 @@ def get_context_data(self, **kwargs): Q(license_error_count__gt=0) | Q(license_warning_count__gt=0) ).count() - products_with_policy_violations = products.filter( - policy_violation_count__gt=0 - ).count() + products_with_policy_violations = products.filter(policy_violation_count__gt=0).count() totals = products.aggregate( total_vulnerabilities=Sum("vulnerability_count"), From 8fdad32779658af0fd651868377fde3be6e0a7a8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 13:30:48 +0400 Subject: [PATCH 17/54] add policy rules configuration inadmin Signed-off-by: tdruez --- dje/admin.py | 120 ++++++++++++++++-- ...aspaceconfiguration_policy_rules_config.py | 2 +- dje/models.py | 4 +- policy/rules.py | 1 + 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/dje/admin.py b/dje/admin.py index 53563ba8..378801e8 100644 --- a/dje/admin.py +++ b/dje/admin.py @@ -96,6 +96,7 @@ from dje.views import manage_tab_permissions_view from dje.views import object_compare_view from dje.views import object_copy_view +from policy.rules import RULE_REGISTRY EXTERNAL_SOURCE_LOOKUP = "external_references__external_source_id" @@ -1046,12 +1047,11 @@ def render(self, name, value, attrs=None, renderer=None): class DataspaceConfigurationForm(forms.ModelForm): - """ - Configure Dataspace settings. + """Configure Dataspace integration settings, with sensitive values hidden in the UI.""" - This form includes fields for various API keys, with sensitive values - hidden in the UI using the HiddenValueWidget. - """ + class Meta: + model = DataspaceConfiguration + exclude = ["policy_rules_config"] hidden_value_fields = [ "scancodeio_api_key", @@ -1078,6 +1078,73 @@ def clean(self): del self.cleaned_data[field_name] +class PolicyRulesConfigurationForm(forms.ModelForm): + """Form for configuring policy rule overrides stored in policy_rules_config.""" + + class Meta: + model = DataspaceConfiguration + fields = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_policy_rule_config_fields() + + def add_policy_rule_config_fields(self): + """Inject per-rule form fields with initial values from the saved policy_rules_config.""" + config = getattr(self.instance, "policy_rules_config", {}) or {} + for rule_type, handler in RULE_REGISTRY.items(): + rule_config = config.get(rule_type, {}) + self.fields[f"rule_{rule_type}_disabled"] = forms.BooleanField( + label="Disable this rule", + required=False, + initial=not rule_config.get("is_active", True), + ) + self.fields[f"rule_{rule_type}_threshold"] = forms.IntegerField( + label="Threshold", + required=False, + min_value=0, + initial=rule_config.get("threshold"), + widget=forms.NumberInput( + attrs={"placeholder": f"Default: {handler.default_threshold}"} + ), + help_text="Minimum violations to trigger the rule. Leave blank to use the default.", + ) + for param_name, param_desc in handler.parameters_schema.items(): + self.fields[f"rule_{rule_type}_param_{param_name}"] = forms.FloatField( + label=param_name.replace("_", " ").title(), + required=False, + initial=(rule_config.get("parameters") or {}).get(param_name), + help_text=param_desc, + ) + + def build_policy_rules_config(self): + """Serialize the per-rule form fields back into the policy_rules_config dict.""" + policy_rules_config = {} + for rule_type, handler in RULE_REGISTRY.items(): + rule_config = {} + if self.cleaned_data.get(f"rule_{rule_type}_disabled"): + rule_config["is_active"] = False + threshold = self.cleaned_data.get(f"rule_{rule_type}_threshold") + if threshold is not None: + rule_config["threshold"] = threshold + parameters = {} + for param_name in handler.parameters_schema: + param_value = self.cleaned_data.get(f"rule_{rule_type}_param_{param_name}") + if param_value is not None: + parameters[param_name] = param_value + if parameters: + rule_config["parameters"] = parameters + if rule_config: + policy_rules_config[rule_type] = rule_config + return policy_rules_config + + def save(self, commit=True): + self.instance.policy_rules_config = self.build_policy_rules_config() + if commit: + self.instance.save(update_fields=["policy_rules_config"]) + return self.instance + + class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline): model = DataspaceConfiguration form = DataspaceConfigurationForm @@ -1141,17 +1208,14 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline): ), ] # Do not include the Dataspace related FKs on addition as the Dataspace does not exist yet - policy_rules_fieldset = ( - "Policy Rules", - {"fields": ("policy_rules_config",)}, - ) - fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets + [policy_rules_fieldset] + fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets + inline_classes = ("grp-collapse grp-open",) can_delete = False def get_fieldsets(self, request, obj=None): if not obj: return self.add_fieldsets - return super().get_fieldsets(request, obj) + return [("", {"fields": ("homepage_layout",)})] + self.add_fieldsets def get_readonly_fields(self, request, obj=None): """Only a user from the current Dataspace can edit Dataspace related FKs.""" @@ -1164,6 +1228,38 @@ def get_readonly_fields(self, request, obj=None): return readonly_fields +class PolicyRulesConfigurationInline(DataspacedFKMixin, admin.StackedInline): + model = DataspaceConfiguration + form = PolicyRulesConfigurationForm + verbose_name_plural = _("Policy Rules Configuration") + verbose_name = _("Policy Rules Configuration") + classes = ("grp-collapse grp-open",) + inline_classes = ("grp-collapse grp-open",) + can_delete = False + + def get_fieldsets(self, request, obj=None): + if not obj: + return [] + rule_fieldsets = [] + for rule_type, handler in RULE_REGISTRY.items(): + fields = [f"rule_{rule_type}_disabled", f"rule_{rule_type}_threshold"] + for param_name in handler.parameters_schema: + fields.append(f"rule_{rule_type}_param_{param_name}") + rule_fieldsets.append(( + handler.label, + { + "fields": fields, + "description": handler.description, + "classes": ("grp-collapse grp-open",), + }, + )) + return rule_fieldsets + + def get_formset(self, request, obj=None, **kwargs): + kwargs["fields"] = [] + return super().get_formset(request, obj, **kwargs) + + @admin.register(Dataspace, site=dejacode_site) class DataspaceAdmin( ReferenceOnlyPermissions, @@ -1243,7 +1339,7 @@ class DataspaceAdmin( ), ) search_fields = ("name",) - inlines = [DataspaceConfigurationInline] + inlines = [DataspaceConfigurationInline, PolicyRulesConfigurationInline] form = DataspaceAdminForm change_form_template = "admin/dje/dataspace/change_form.html" change_list_template = "admin/change_list_extended.html" diff --git a/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py b/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py index 8d77f9e8..340b8226 100644 --- a/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py +++ b/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='dataspaceconfiguration', name='policy_rules_config', - field=models.JSONField(blank=True, default=dict, help_text='Override default policy rule settings for this dataspace. '), + field=models.JSONField(blank=True, default=dict, help_text='Override default policy rule settings for this dataspace.'), ), ] diff --git a/dje/models.py b/dje/models.py index e263ceda..996195ce 100644 --- a/dje/models.py +++ b/dje/models.py @@ -617,9 +617,7 @@ class DataspaceConfiguration(DataspaceForeignKeyValidationMixin, models.Model): policy_rules_config = models.JSONField( blank=True, default=dict, - help_text=_( - "Override default policy rule settings for this dataspace. " - ), + help_text=_("Override default policy rule settings for this dataspace."), ) def __str__(self): diff --git a/policy/rules.py b/policy/rules.py index 748e5d15..c794211c 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -15,6 +15,7 @@ class BaseRule: rule_type = None label = None description = None + default_threshold = 0 parameters_schema = {} def count_violations(self, product, threshold, parameters): From 12ea0deba800ee798a17393a92c66d1244b812ed Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 13:39:10 +0400 Subject: [PATCH 18/54] resolve open violations Signed-off-by: tdruez --- policy/engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/policy/engine.py b/policy/engine.py index bf9ea716..1c16146a 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -67,6 +67,11 @@ def evaluate_rules(product): for rule_type in RULE_REGISTRY: config = get_effective_config(rule_type, product.dataspace) if not config["is_active"]: + # Explicitly resolve open violations so disabling a rule clears its history + # rather than leaving stale unresolved records. + ProductPolicyViolation.objects.filter( + rule_type=rule_type, product=product, resolved=False, + ).update(resolved=True, resolved_date=timezone.now()) continue violation = evaluate_rule(rule_type, product, config["threshold"], config["parameters"]) From a576b6db9d504ac22cedc39bec869702bd5cdfd3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 13:52:05 +0400 Subject: [PATCH 19/54] add signals to trigger rule evaluation Signed-off-by: tdruez --- dje/admin.py | 18 ++++++++++-------- policy/apps.py | 3 +++ policy/engine.py | 4 +++- policy/signals.py | 23 +++++++++++++++++++++++ policy/tasks.py | 11 ++++++++++- product_portfolio/views.py | 4 +--- 6 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 policy/signals.py diff --git a/dje/admin.py b/dje/admin.py index 378801e8..02d1c9f3 100644 --- a/dje/admin.py +++ b/dje/admin.py @@ -1245,14 +1245,16 @@ def get_fieldsets(self, request, obj=None): fields = [f"rule_{rule_type}_disabled", f"rule_{rule_type}_threshold"] for param_name in handler.parameters_schema: fields.append(f"rule_{rule_type}_param_{param_name}") - rule_fieldsets.append(( - handler.label, - { - "fields": fields, - "description": handler.description, - "classes": ("grp-collapse grp-open",), - }, - )) + rule_fieldsets.append( + ( + handler.label, + { + "fields": fields, + "description": handler.description, + "classes": ("grp-collapse grp-open",), + }, + ) + ) return rule_fieldsets def get_formset(self, request, obj=None, **kwargs): diff --git a/policy/apps.py b/policy/apps.py index e67945dc..392564e7 100755 --- a/policy/apps.py +++ b/policy/apps.py @@ -13,3 +13,6 @@ class PolicyConfig(AppConfig): name = "policy" verbose_name = _("Policy") + + def ready(self): + import policy.signals # noqa: F401 diff --git a/policy/engine.py b/policy/engine.py index 1c16146a..b1c1c96b 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -70,7 +70,9 @@ def evaluate_rules(product): # Explicitly resolve open violations so disabling a rule clears its history # rather than leaving stale unresolved records. ProductPolicyViolation.objects.filter( - rule_type=rule_type, product=product, resolved=False, + rule_type=rule_type, + product=product, + resolved=False, ).update(resolved=True, resolved_date=timezone.now()) continue diff --git a/policy/signals.py b/policy/signals.py new file mode 100644 index 00000000..17f0d948 --- /dev/null +++ b/policy/signals.py @@ -0,0 +1,23 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from policy.tasks import evaluate_product_rules_task + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender="product_portfolio.Product") +def evaluate_product_rules_on_save(sender, instance, **kwargs): + """Queue a policy rule evaluation whenever a product is saved.""" + logger.debug(f"Queuing policy rule evaluation for product {instance.uuid}") + evaluate_product_rules_task.delay(product_uuid=instance.uuid) diff --git a/policy/tasks.py b/policy/tasks.py index b6523449..9e3abb36 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -6,12 +6,16 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +import logging + from django.apps import apps from django_rq import job from policy.engine import evaluate_rules +logger = logging.getLogger(__name__) + @job def evaluate_product_rules_task(product_uuid): @@ -21,9 +25,12 @@ def evaluate_product_rules_task(product_uuid): try: product = Product.objects.select_related("dataspace").get(uuid=product_uuid) except Product.DoesNotExist: + logger.warning(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.") return - evaluate_rules(product) + logger.info(f"Evaluating policy rules for product {product}") + violations = evaluate_rules(product) + logger.info(f"Policy rules evaluated for {product}: {len(violations)} active violation(s).") @job @@ -35,5 +42,7 @@ def evaluate_all_products_rules_task(include_locked=False): if not include_locked: products = products.exclude_locked() + count = products.count() + logger.info(f"Queuing policy rule evaluation for {count} product(s).") for product in products: evaluate_product_rules_task.delay(product_uuid=product.uuid) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 0083f70d..5d7dd2bd 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2845,9 +2845,7 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): - policy_violations = product.policy_violations.filter(resolved=False).select_related( - "policy_rule" - ) + policy_violations = product.policy_violations.filter(resolved=False) return { "policy_violations": policy_violations, "policy_violation_count": policy_violations.count(), From 406cd163d9e2d74180b0a0cf3fbf885341e5ce6f Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 14:00:51 +0400 Subject: [PATCH 20/54] fix rendering Signed-off-by: tdruez --- .../compliance/compliance_panels.html | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index b4f5a5cc..1c0fce7a 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -176,32 +176,42 @@

{% trans "Security compliance" %}

{% endif %} {# Vulnerability list #} - {% for vulnerability in vulnerabilities %} -
- - {% if vulnerability.risk_level == "critical" %} - {% trans "Critical" %} - {% elif vulnerability.risk_level == "high" %} - {% trans "High" %} - {% elif vulnerability.risk_level == "medium" %} - {% trans "Medium" %} - {% elif vulnerability.risk_level == "low" %} - {% trans "Low" %} - {% else %} - {% trans "Unknown" %} - {% endif %} - - - {{ vulnerability.advisory_id }} - - {{ vulnerability.summary|truncatechars:70 }} -
- {% empty %} -
- - {% trans "No known vulnerabilities" %} -
- {% endfor %} + + + {% for vulnerability in vulnerabilities %} + + + + + + {% empty %} + + + + {% endfor %} + +
+ + {% if vulnerability.risk_level == "critical" %} + {% trans "Critical" %} + {% elif vulnerability.risk_level == "high" %} + {% trans "High" %} + {% elif vulnerability.risk_level == "medium" %} + {% trans "Medium" %} + {% elif vulnerability.risk_level == "low" %} + {% trans "Low" %} + {% else %} + {% trans "Unknown" %} + {% endif %} + + + + {{ vulnerability.advisory_id }} + + {{ vulnerability.summary|truncatechars:70 }}
+ + {% trans "No known vulnerabilities" %} +
{# View all link #} {% if vulnerabilities|length < vulnerability_count %} From 19a1c9656e8dbd40b0f23ede019ec79279cc9919 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 14:22:52 +0400 Subject: [PATCH 21/54] fix bug in view Signed-off-by: tdruez --- product_portfolio/models.py | 4 ++-- product_portfolio/views.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 6a94d2e2..4b01ddd7 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -441,9 +441,9 @@ def save(self, *args, **kwargs): if self.has_changed("configuration_status_id"): self.actions_on_status_change() - from policy.tasks import evaluate_product_rules_task + from policy.engine import evaluate_rules - evaluate_product_rules_task.delay(product_uuid=self.uuid) + evaluate_rules(product=self) def get_attribution_url(self): return self.get_url("attribution") diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 5d7dd2bd..883441f2 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -422,7 +422,10 @@ def tab_terms(self): if self.object.notice_text: notice_field = self.get_tab_fields([TabField("notice_text")])[0] - tab_data["fields"].append(notice_field) + if tab_data is None: + tab_data = {"fields": [notice_field]} + else: + tab_data["fields"].append(notice_field) return tab_data From 34597f782c50ce4669bcaededfc6c1d6db32fa57 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 14:32:47 +0400 Subject: [PATCH 22/54] use porper manager Signed-off-by: tdruez --- policy/tasks.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/policy/tasks.py b/policy/tasks.py index 9e3abb36..9dab6ad2 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -12,6 +12,7 @@ from django_rq import job +from dje.models import get_unsecured_manager from policy.engine import evaluate_rules logger = logging.getLogger(__name__) @@ -23,9 +24,9 @@ def evaluate_product_rules_task(product_uuid): Product = apps.get_model("product_portfolio", "product") try: - product = Product.objects.select_related("dataspace").get(uuid=product_uuid) + product = Product.unsecured_objects.select_related("dataspace").get(uuid=product_uuid) except Product.DoesNotExist: - logger.warning(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.") + logger.error(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.") return logger.info(f"Evaluating policy rules for product {product}") @@ -38,7 +39,7 @@ def evaluate_all_products_rules_task(include_locked=False): """Enqueue evaluate_product_rules_task for every product, skipping locked ones by default.""" Product = apps.get_model("product_portfolio", "product") - products = Product.objects.select_related("dataspace") + products = Product.unsecured_objects.select_related("dataspace") if not include_locked: products = products.exclude_locked() From f8287c29aa982e493df6aac550ff8207520a6762 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 17:29:35 +0400 Subject: [PATCH 23/54] do not display delivery history on addition Signed-off-by: tdruez --- notification/admin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/notification/admin.py b/notification/admin.py index d683bec2..4cf592b3 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -64,3 +64,8 @@ class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin): actions_to_remove = ["copy_to", "compare_with"] email_notification_on = () inlines = [WebhookDeliveryInline] + + def get_inlines(self, request, obj=None): + if obj is None: + return [] + return super().get_inlines(request, obj) From b482699430b9256c0d45a8e3d084abca4d849a9f Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 17:29:57 +0400 Subject: [PATCH 24/54] implement fire_policy_webhooks Signed-off-by: tdruez --- notification/admin.py | 1 + notification/models.py | 2 ++ policy/engine.py | 41 ++++++++++++++++++++++------------- policy/tasks.py | 49 +++++++++++++++++++++++++++++++++++------- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/notification/admin.py b/notification/admin.py index 4cf592b3..a9d74656 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -66,6 +66,7 @@ class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin): inlines = [WebhookDeliveryInline] def get_inlines(self, request, obj=None): + """Exclude delivery history on the add form as no deliveries exist yet.""" if obj is None: return [] return super().get_inlines(request, obj) diff --git a/notification/models.py b/notification/models.py index 661ef5a4..b8676a70 100644 --- a/notification/models.py +++ b/notification/models.py @@ -28,6 +28,8 @@ "user.added_or_updated", "user.locked_out", "vulnerability.data_update", + "policy.violation_detected", + "policy.violation_resolved", ] diff --git a/policy/engine.py b/policy/engine.py index b1c1c96b..142b005b 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -33,7 +33,11 @@ def get_effective_config(rule_type, dataspace): def evaluate_rule(rule_type, product, threshold, parameters): - """Evaluate a single rule against a product and record the violation if triggered.""" + """ + Evaluate a single rule against a product and record the violation if triggered. + + Returns a 3-tuple: (violation_or_none, created, resolved_count). + """ handler = RULE_REGISTRY[rule_type] violation_count = handler.count_violations(product, threshold, parameters) @@ -47,37 +51,44 @@ def evaluate_rule(rule_type, product, threshold, parameters): if not created: violation.violation_count = violation_count violation.save() - return violation + return violation, created, 0 - else: - ProductPolicyViolation.objects.filter(**lookup).update( - resolved=True, - resolved_date=timezone.now(), - ) - return + resolved_count = ProductPolicyViolation.objects.filter(**lookup).update( + resolved=True, + resolved_date=timezone.now(), + ) + return None, False, resolved_count def evaluate_rules(product): """ Evaluate all rules in RULE_REGISTRY for the given product. - Returns the list of active ProductPolicyViolation instances. + Returns a 2-tuple: (new_violations, resolved_count). + new_violations is a list of newly created ProductPolicyViolation instances. + resolved_count is the total number of violations resolved during this run. """ - violations = [] + new_violations = [] + resolved_count = 0 + for rule_type in RULE_REGISTRY: config = get_effective_config(rule_type, product.dataspace) if not config["is_active"]: # Explicitly resolve open violations so disabling a rule clears its history # rather than leaving stale unresolved records. - ProductPolicyViolation.objects.filter( + rows = ProductPolicyViolation.objects.filter( rule_type=rule_type, product=product, resolved=False, ).update(resolved=True, resolved_date=timezone.now()) + resolved_count += rows continue - violation = evaluate_rule(rule_type, product, config["threshold"], config["parameters"]) - if violation: - violations.append(violation) + violation, created, resolved = evaluate_rule( + rule_type, product, config["threshold"], config["parameters"] + ) + if created: + new_violations.append(violation) + resolved_count += resolved - return violations + return new_violations, resolved_count diff --git a/policy/tasks.py b/policy/tasks.py index 9dab6ad2..237b3796 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -13,37 +13,70 @@ from django_rq import job from dje.models import get_unsecured_manager +from notification.models import fire_webhooks from policy.engine import evaluate_rules logger = logging.getLogger(__name__) +def fire_policy_webhooks(product, new_violations, resolved_count): + """Fire policy webhooks for newly detected or resolved violations.""" + if new_violations: + lines = [ + f"- {violation.rule_label}: {violation.violation_count} violation(s)" + for violation in new_violations + ] + payload = { + "text": (f"[DejaCode] Policy violations detected for {product}\n" + "\n".join(lines)) + } + fire_webhooks("policy.violation_detected", instance=product, payload_override=payload) + + if resolved_count: + payload = { + "text": (f"[DejaCode] {resolved_count} policy violation(s) resolved for {product}") + } + fire_webhooks("policy.violation_resolved", instance=product, payload_override=payload) + + @job def evaluate_product_rules_task(product_uuid): - """Evaluate all active PolicyRules for the given product.""" + """Evaluate all active policy rules for the given product and fire webhooks on changes.""" Product = apps.get_model("product_portfolio", "product") try: - product = Product.unsecured_objects.select_related("dataspace").get(uuid=product_uuid) + product = get_unsecured_manager(Product).get(uuid=product_uuid) except Product.DoesNotExist: logger.error(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.") return logger.info(f"Evaluating policy rules for product {product}") - violations = evaluate_rules(product) - logger.info(f"Policy rules evaluated for {product}: {len(violations)} active violation(s).") + new_violations, resolved_count = evaluate_rules(product) + logger.info( + f"Policy rules evaluated for {product}: " + f"{len(new_violations)} new violation(s), {resolved_count} resolved." + ) + fire_policy_webhooks(product, new_violations, resolved_count) @job def evaluate_all_products_rules_task(include_locked=False): - """Enqueue evaluate_product_rules_task for every product, skipping locked ones by default.""" + """Evaluate policy rules for every product directly, skipping locked ones by default.""" Product = apps.get_model("product_portfolio", "product") - products = Product.unsecured_objects.select_related("dataspace") + products = get_unsecured_manager(Product).select_related("dataspace") if not include_locked: products = products.exclude_locked() count = products.count() - logger.info(f"Queuing policy rule evaluation for {count} product(s).") + logger.info(f"Starting policy rule evaluation for {count} product(s).") + for product in products: - evaluate_product_rules_task.delay(product_uuid=product.uuid) + logger.info(f"Evaluating policy rules for product {product}") + new_violations, resolved_count = evaluate_rules(product) + logger.info( + f"Policy rules evaluated for {product}: " + f"{len(new_violations)} new violation(s), {resolved_count} resolved." + ) + fire_policy_webhooks(product, new_violations, resolved_count) + + logger.info(f"Policy rule evaluation complete for {count} product(s).") From ee5e986a99fe956f51f250be326e0bf95fbbd077 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 17:47:57 +0400 Subject: [PATCH 25/54] add filter by policy rule Signed-off-by: tdruez --- policy/rules.py | 10 ++++++++++ product_portfolio/filters.py | 9 +++++++++ .../compliance/compliance_panels.html | 7 ++++++- .../product_portfolio/compliance/metric_cards.html | 2 +- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/policy/rules.py b/policy/rules.py index c794211c..481e3983 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -22,6 +22,10 @@ def count_violations(self, product, threshold, parameters): """Count objects violating the rule for the given product.""" raise NotImplementedError + def get_package_filter(self): + """Return queryset filter kwargs for ProductPackage to identify violating packages.""" + return {} + class PackageBaseRule(BaseRule): """Base for rules that count packages matching a fixed filter within a product.""" @@ -38,6 +42,9 @@ def count_violations(self, product, threshold, parameters): return count if count > threshold else 0 + def get_package_filter(self): + return {f"package__{key}": value for key, value in self.package_filter.items()} + class LicensePolicyErrorRule(PackageBaseRule): rule_type = "license_policy_error" @@ -74,6 +81,9 @@ class VulnerabilityDetectedRule(BaseRule): "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.", } + def get_package_filter(self): + return {"package__risk_score__isnull": False} + def count_violations(self, product, threshold, parameters): Package = apps.get_model("component_catalog", "package") diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 846d6d8b..359f422f 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -31,6 +31,7 @@ from dje.widgets import DropDownRightWidget from dje.widgets import DropDownWidget from license_library.models import License +from policy.rules import RULE_REGISTRY from product_portfolio.models import CodebaseResource from product_portfolio.models import Product from product_portfolio.models import ProductComponent @@ -407,6 +408,7 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): field_name="package__usage_policy__compliance_alert", distinct=True, ) + policy_rule = django_filters.CharFilter(method="filter_by_policy_rule") class Meta: model = ProductPackage @@ -422,6 +424,13 @@ class Meta: "exploitability", ] + def filter_by_policy_rule(self, queryset, name, value): + """Filter packages that triggered the given policy rule type.""" + handler = RULE_REGISTRY.get(value) + if not handler: + return queryset + return queryset.filter(**handler.get_package_filter()) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)" diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 1c0fce7a..ce9632ff 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -250,7 +250,12 @@

{% trans "Policy violations" %}

{{ violation.rule_label }} {{ violation.rule_description }} - {{ violation.violation_count }} + + + {{ violation.violation_count }} + + {{ violation.detected_date|date:"N j, Y" }} {% endfor %} diff --git a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html index 91c6db46..171c0657 100644 --- a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html +++ b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html @@ -84,7 +84,7 @@ {% else %}
{{ policy_violation_count }}
From 976ffec55ce510e7cc6c9b0866c5f0010e624a27 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 19:03:58 +0400 Subject: [PATCH 26/54] fix names for usage policy rules Signed-off-by: tdruez --- policy/rules.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/policy/rules.py b/policy/rules.py index 481e3983..d7cbcb7c 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -46,18 +46,18 @@ def get_package_filter(self): return {f"package__{key}": value for key, value in self.package_filter.items()} -class LicensePolicyErrorRule(PackageBaseRule): - rule_type = "license_policy_error" - label = "License Policy Error" +class UsagePolicyErrorRule(PackageBaseRule): + rule_type = "usage_policy_error" + label = "Usage Policy Error" description = ( "Detects packages assigned a usage policy with a compliance alert level of 'error'." ) package_filter = {"usage_policy__compliance_alert": "error"} -class LicensePolicyWarningRule(PackageBaseRule): - rule_type = "license_policy_warning" - label = "License Policy Warning" +class UsagePolicyWarningRule(PackageBaseRule): + rule_type = "usage_policy_warning" + label = "Usage Policy Warning" description = ( "Detects packages assigned a usage policy with a compliance alert level of 'warning'." ) @@ -101,8 +101,8 @@ def count_violations(self, product, threshold, parameters): RULE_REGISTRY = { - LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(), - LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(), + UsagePolicyErrorRule.rule_type: UsagePolicyErrorRule(), + UsagePolicyWarningRule.rule_type: UsagePolicyWarningRule(), LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(), VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(), } From 0790847319e08e93878b52101c2492a9212965fc Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Jul 2026 19:13:22 +0400 Subject: [PATCH 27/54] add license policy rules Signed-off-by: tdruez --- policy/rules.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/policy/rules.py b/policy/rules.py index d7cbcb7c..824d97ea 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -38,7 +38,7 @@ def count_violations(self, product, threshold, parameters): count = Package.objects.filter( productpackages__product=product, **self.package_filter, - ).count() + ).distinct().count() return count if count > threshold else 0 @@ -64,6 +64,26 @@ class UsagePolicyWarningRule(PackageBaseRule): package_filter = {"usage_policy__compliance_alert": "warning"} +class LicensePolicyErrorRule(PackageBaseRule): + rule_type = "license_policy_error" + label = "License Policy Error" + description = ( + "Detects packages whose licenses are assigned a usage policy" + " with a compliance alert level of 'error'." + ) + package_filter = {"licenses__usage_policy__compliance_alert": "error"} + + +class LicensePolicyWarningRule(PackageBaseRule): + rule_type = "license_policy_warning" + label = "License Policy Warning" + description = ( + "Detects packages whose licenses are assigned a usage policy" + " with a compliance alert level of 'warning'." + ) + package_filter = {"licenses__usage_policy__compliance_alert": "warning"} + + class LicenseCoverageGapRule(PackageBaseRule): rule_type = "license_coverage_gap" label = "License Coverage Gap" @@ -103,6 +123,8 @@ def count_violations(self, product, threshold, parameters): RULE_REGISTRY = { UsagePolicyErrorRule.rule_type: UsagePolicyErrorRule(), UsagePolicyWarningRule.rule_type: UsagePolicyWarningRule(), + LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(), + LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(), LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(), VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(), } From ec0137d783d71d5e727af0514588e256644c8605 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 09:18:17 +0400 Subject: [PATCH 28/54] add a hourly cron to evaluate all rules Signed-off-by: tdruez --- dejacode/settings.py | 2 ++ dje/cron_jobs.py | 8 ++++++++ policy/rules.py | 12 ++++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/dejacode/settings.py b/dejacode/settings.py index 8adfdf39..7c9af8c3 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -475,7 +475,9 @@ def gettext_noop(s): # Cron jobs (scheduler) daily_at_3am = "0 3 * * *" +hourly = "0 * * * *" DEJACODE_VULNERABILITIES_CRON = env.str("DEJACODE_VULNERABILITIES_CRON", default=daily_at_3am) +DEJACODE_POLICY_RULES_CRON = env.str("DEJACODE_POLICY_RULES_CRON", default=hourly) def enable_rq_eager_mode(): diff --git a/dje/cron_jobs.py b/dje/cron_jobs.py index 38cd35fc..1631a15c 100644 --- a/dje/cron_jobs.py +++ b/dje/cron_jobs.py @@ -11,6 +11,7 @@ from rq import cron from dje.tasks import update_vulnerabilities +from policy.tasks import evaluate_all_products_rules_task two_hour = 7200 @@ -20,3 +21,10 @@ cron=settings.DEJACODE_VULNERABILITIES_CRON, # Daily at 3am by default job_timeout=two_hour, ) + +cron.register( + func=evaluate_all_products_rules_task, + queue_name="default", + cron=settings.DEJACODE_POLICY_RULES_CRON, # Hourly by default + job_timeout=two_hour, +) diff --git a/policy/rules.py b/policy/rules.py index 824d97ea..1778920c 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -35,10 +35,14 @@ class PackageBaseRule(BaseRule): def count_violations(self, product, threshold, parameters): Package = apps.get_model("component_catalog", "package") - count = Package.objects.filter( - productpackages__product=product, - **self.package_filter, - ).distinct().count() + count = ( + Package.objects.filter( + productpackages__product=product, + **self.package_filter, + ) + .distinct() + .count() + ) return count if count > threshold else 0 From ec0007707336a85c749e39ef59fd5497061c2de9 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 09:43:10 +0400 Subject: [PATCH 29/54] rework the compliance tab UI Signed-off-by: tdruez --- .../compliance/compliance_panels.html | 83 ++++++++++--------- .../compliance/metric_cards.html | 43 ++++------ product_portfolio/views.py | 2 +- 3 files changed, 59 insertions(+), 69 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index ce9632ff..5575f019 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -1,5 +1,46 @@ {% load i18n %} {% url product.get_absolute_url as product_url %} + +{% if policy_violations %} +
+
+
+
+

{% trans "Policy violations" %}

+ + {{ policy_violation_count }} {% trans "active" %} + +
+ + + + + + + + + + + {% for violation in policy_violations %} + + + + + + + {% endfor %} + +
{% trans "Rule" %}{% trans "Description" %}{% trans "Objects in violation" %}{% trans "Detected" %}
{{ violation.rule_label }}{{ violation.rule_description }} + + {{ violation.violation_count }} + + {{ violation.detected_date|date:"N j, Y" }}
+
+
+
+{% endif %} +
{# License panel #}
@@ -224,44 +265,4 @@

{% trans "Security compliance" %}

{% endif %}
-
- -{% if policy_violations %} -
-
-
-
-

{% trans "Policy violations" %}

- - {{ policy_violation_count }} {% trans "active" %} - -
- - - - - - - - - - - {% for violation in policy_violations %} - - - - - - - {% endfor %} - -
{% trans "Rule" %}{% trans "Description" %}{% trans "Objects in violation" %}{% trans "Detected" %}
{{ violation.rule_label }}{{ violation.rule_description }} - - {{ violation.violation_count }} - - {{ violation.detected_date|date:"N j, Y" }}
-
-
-
-{% endif %} \ No newline at end of file +
\ No newline at end of file diff --git a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html index 171c0657..14bd04e8 100644 --- a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html +++ b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html @@ -2,17 +2,18 @@
@@ -63,29 +64,17 @@ {% elif risk_threshold_number %}
{{ above_threshold_count }}
- {% trans "of" %} {{ vulnerability_count }} {% trans "above threshold of" %} {{ risk_threshold_number }} + + {% trans "of" %} {{ vulnerability_count }} {% trans "above threshold of" %} {{ risk_threshold_number }} +
{% else %}
{{ vulnerability_count }}
- {% if critical_count %}{{ critical_count }} {% trans "critical" %}{% endif %}{% if critical_count and high_count %}, {% endif %}{% if high_count %}{{ high_count }} {% trans "high" %}{% endif %}{% if medium_count and critical_count or medium_count and high_count %}, {% endif %}{% if medium_count %}{{ medium_count }} {% trans "medium" %}{% endif %}{% if low_count and vulnerability_count == low_count %}{{ low_count }} {% trans "low" %}{% endif %} -
- {% endif %} -
-
-
-
-
{% trans "Policy violations" %}
- {% if policy_violation_count == 0 %} -
0
-
{% trans "No active violations" %}
- {% else %} -
{{ policy_violation_count }}
- {% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 883441f2..385afc35 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2848,7 +2848,7 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): - policy_violations = product.policy_violations.filter(resolved=False) + policy_violations = product.policy_violations.filter(resolved=False).order_by("rule_type") return { "policy_violations": policy_violations, "policy_violation_count": policy_violations.count(), From 158df92ffc4a1ee6e6a69b88b0b66becb52ae618 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 09:47:56 +0400 Subject: [PATCH 30/54] adjust compliance dashboard Signed-off-by: tdruez --- .../compliance/compliance_dashboard.html | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html index 77a99961..cda97072 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html @@ -66,6 +66,27 @@

+
+
+
{% trans "Policy violations" %}
+ {% if products_with_policy_violations %} + + {{ products_with_policy_violations }} + + {% else %} +
+ {{ products_with_policy_violations }} +
+ {% endif %} +
+ {% if products_with_policy_violations %} + {% trans "products with active rule violations" %} + {% else %} + {% trans "No active policy violations" %} + {% endif %} +
+
+
{% trans "License compliance" %}
@@ -104,27 +125,6 @@

-
-
-
{% trans "Policy violations" %}
- {% if products_with_policy_violations %} - - {{ products_with_policy_violations }} - - {% else %} -
- {{ products_with_policy_violations }} -
- {% endif %} -
- {% if products_with_policy_violations %} - {% trans "products with active rule violations" %} - {% else %} - {% trans "No active policy violations" %} - {% endif %} -
-
-
@@ -133,9 +133,9 @@

{% trans "Product" %} {% trans "Packages" %} + {% trans "Policy violations" %} {% trans "License compliance" %} {% trans "Vulnerabilities" %} - {% trans "Policy violations" %} @@ -152,6 +152,15 @@

{{ product.package_count|intcomma }} + + {% if product.policy_violation_count %} + + {{ product.policy_violation_count }} {% trans "rule" %}{{ product.policy_violation_count|pluralize }} {% trans "triggered" %} + + {% else %} + {% trans "None" %} + {% endif %} + {% if product.license_error_count %} @@ -189,15 +198,6 @@

{% trans "None" %} {% endif %} - - {% if product.policy_violation_count %} - - {{ product.policy_violation_count }} {% trans "rule" %}{{ product.policy_violation_count|pluralize }} {% trans "triggered" %} - - {% else %} - {% trans "None" %} - {% endif %} - {% endwith %} {% empty %} From 584ee9d23dbecb00df63626059de619d6c8dea16 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 10:18:24 +0400 Subject: [PATCH 31/54] refine polivy violation table Signed-off-by: tdruez --- policy/rules.py | 4 ++ product_portfolio/models.py | 10 +-- .../compliance/compliance_panels.html | 65 +++++++++++++++++-- product_portfolio/views.py | 14 ++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/policy/rules.py b/policy/rules.py index 1778920c..94d56a15 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -15,6 +15,7 @@ class BaseRule: rule_type = None label = None description = None + severity = "warning" default_threshold = 0 parameters_schema = {} @@ -53,6 +54,7 @@ def get_package_filter(self): class UsagePolicyErrorRule(PackageBaseRule): rule_type = "usage_policy_error" label = "Usage Policy Error" + severity = "error" description = ( "Detects packages assigned a usage policy with a compliance alert level of 'error'." ) @@ -71,6 +73,7 @@ class UsagePolicyWarningRule(PackageBaseRule): class LicensePolicyErrorRule(PackageBaseRule): rule_type = "license_policy_error" label = "License Policy Error" + severity = "error" description = ( "Detects packages whose licenses are assigned a usage policy" " with a compliance alert level of 'error'." @@ -100,6 +103,7 @@ class LicenseCoverageGapRule(PackageBaseRule): class VulnerabilityDetectedRule(BaseRule): rule_type = "vulnerability_detected" label = "Vulnerability Detected" + severity = "error" description = "Detects packages with at least one known vulnerability (non-null risk score)." parameters_schema = { "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.", diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 4b01ddd7..1f90df56 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -58,6 +58,7 @@ from dje.validators import validate_url_segment from dje.validators import validate_version from policy.models import AbstractPolicyViolation +from policy.rules import RULE_REGISTRY from vulnerabilities.fetch import fetch_for_packages from vulnerabilities.models import AffectedByVulnerabilityMixin from vulnerabilities.models import AffectedByVulnerabilityRelationship @@ -1956,14 +1957,15 @@ def __str__(self): @property def rule_label(self): - from policy.rules import RULE_REGISTRY - handler = RULE_REGISTRY.get(self.rule_type) return handler.label if handler else self.rule_type @property def rule_description(self): - from policy.rules import RULE_REGISTRY - handler = RULE_REGISTRY.get(self.rule_type) return handler.description if handler else "" + + @property + def rule_severity(self): + handler = RULE_REGISTRY.get(self.rule_type) + return handler.severity if handler else "warning" diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 5575f019..de795bae 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -6,32 +6,46 @@
-

{% trans "Policy violations" %}

+
+

{% trans "Policy violations" %}

+ +
{{ policy_violation_count }} {% trans "active" %}
- +
- - + + {% for violation in policy_violations %} - + - - + {% endfor %} @@ -39,6 +53,43 @@

{% trans "Policy violations" %}

+ + {# Hidden popover content -- not data-bs-toggle so the global init doesn't conflict #} +
+ {% for rule in all_rules %} +
+ {{ rule.label }} + {% if not rule.is_active %} + {% trans "Disabled" %} + {% elif rule.is_violated %} + {% if rule.severity == "error" %} + {% trans "Triggered" %} + {% else %} + {% trans "Triggered" %} + {% endif %} + {% else %} + {% trans "OK" %} + {% endif %} +
+ {% endfor %} +
+ {% endif %}
diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 385afc35..7686267e 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -143,6 +143,8 @@ from product_portfolio.models import ScanCodeProject from product_portfolio.tasks import improve_packages_from_purldb_task from product_portfolio.tasks import pull_project_data_from_scancodeio_task +from policy.engine import get_effective_config +from policy.rules import RULE_REGISTRY from vulnerabilities.forms import VulnerabilityAnalysisForm from vulnerabilities.models import AffectedByVulnerabilityMixin from vulnerabilities.models import Vulnerability @@ -2849,9 +2851,21 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): policy_violations = product.policy_violations.filter(resolved=False).order_by("rule_type") + violated_rule_types = {violation.rule_type for violation in policy_violations} + all_rules = [ + { + "label": handler.label, + "rule_type": rule_type, + "severity": handler.severity, + "is_active": get_effective_config(rule_type, product.dataspace)["is_active"], + "is_violated": rule_type in violated_rule_types, + } + for rule_type, handler in RULE_REGISTRY.items() + ] return { "policy_violations": policy_violations, "policy_violation_count": policy_violations.count(), + "all_rules": all_rules, } @staticmethod From 5065b587d4d0dac21a22452a3b5e8ad2aed6afde Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 15:25:53 +0400 Subject: [PATCH 32/54] replace popover with modal Signed-off-by: tdruez --- .../compliance/compliance_panels.html | 79 ++++++++++--------- product_portfolio/views.py | 1 + 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index de795bae..25a730c1 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -9,12 +9,51 @@

{% trans "Policy violations" %}

+ + {{ policy_violation_count }} {% trans "active" %} @@ -54,42 +93,6 @@

{% trans "Policy violations" %}

- {# Hidden popover content -- not data-bs-toggle so the global init doesn't conflict #} -
- {% for rule in all_rules %} -
- {{ rule.label }} - {% if not rule.is_active %} - {% trans "Disabled" %} - {% elif rule.is_violated %} - {% if rule.severity == "error" %} - {% trans "Triggered" %} - {% else %} - {% trans "Triggered" %} - {% endif %} - {% else %} - {% trans "OK" %} - {% endif %} -
- {% endfor %} -
- {% endif %}
@@ -262,7 +265,7 @@

{% trans "Security compliance" %}

{% else %} {{ vulnerability_count }} {% trans "vulnerabilit" %}{{ vulnerability_count|pluralize:"y,ies" }} - — {% trans "no risk threshold set" %} + - {% trans "no risk threshold set" %} {% endif %}

{% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 7686267e..f46ca5e7 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2855,6 +2855,7 @@ def get_policy_compliance_context(product): all_rules = [ { "label": handler.label, + "description": handler.description, "rule_type": rule_type, "severity": handler.severity, "is_active": get_effective_config(rule_type, product.dataspace)["is_active"], From 4ca34f5e135b258d6bca0aed21e8d6455ecf344e Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 16:04:19 +0400 Subject: [PATCH 33/54] add ability to re-evaluate rules Signed-off-by: tdruez --- product_portfolio/models.py | 3 + .../compliance/compliance_panels.html | 82 +++++++++++-------- product_portfolio/urls.py | 2 + product_portfolio/views.py | 19 +++++ 4 files changed, 73 insertions(+), 33 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 1f90df56..0ba9353f 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -491,6 +491,9 @@ def get_export_license_compliance_url(self): def get_export_security_compliance_url(self): return self.get_url("export_security_compliance") + def get_evaluate_policy_rules_url(self): + return self.get_url("evaluate_policy_rules") + @property def cyclonedx_bom_ref(self): return str(self.uuid) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 25a730c1..550bc451 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -16,47 +16,63 @@

{% trans "Policy violations" %}

+
+ {% if has_change_permission %} +
+ {% csrf_token %} + + + {% endif %} + + {{ policy_violation_count }} {% trans "active" %} + +
+ -
{% trans "Rule" %} {% trans "Description" %}{% trans "Objects in violation" %}{% trans "Detected" %}{% trans "In violation" %}{% trans "Detected" %}
{{ violation.rule_label }} + {% if violation.rule_severity == "error" %} + {{ violation.rule_label }} + {% else %} + {{ violation.rule_label }} + {% endif %} + {{ violation.rule_description }} + {{ violation.violation_count }} {{ violation.detected_date|date:"N j, Y" }}{{ violation.detected_date|date:"N j, Y" }}
diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index 1c84b51f..8231594a 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -42,6 +42,7 @@ from product_portfolio.views import check_package_version_ajax_view from product_portfolio.views import delete_scan_htmx_view from product_portfolio.views import edit_productrelation_ajax_view +from product_portfolio.views import evaluate_policy_rules_view from product_portfolio.views import import_from_scan_view from product_portfolio.views import import_packages_from_scancodeio_view from product_portfolio.views import improve_packages_from_purldb_view @@ -129,6 +130,7 @@ def product_path(path_segment, view): *product_path("add_customcomponent_ajax", add_customcomponent_ajax_view), *product_path("vulnerability_analysis_form", vulnerability_analysis_form_view), *product_path("scan_all_packages", scan_all_packages_view), + *product_path("evaluate_policy_rules", evaluate_policy_rules_view), *product_path("improve_packages_from_purldb", improve_packages_from_purldb_view), *product_path("about_files", ProductSendAboutFilesView.as_view()), *product_path("export_spdx", ProductExportSPDXDocumentView.as_view()), diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f46ca5e7..7441b834 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -143,6 +143,7 @@ from product_portfolio.models import ScanCodeProject from product_portfolio.tasks import improve_packages_from_purldb_task from product_portfolio.tasks import pull_project_data_from_scancodeio_task +from policy.engine import evaluate_rules from policy.engine import get_effective_config from policy.rules import RULE_REGISTRY from vulnerabilities.forms import VulnerabilityAnalysisForm @@ -2068,6 +2069,22 @@ def scan_all_packages_view(request, dataspace, name, version=""): return redirect(product) +@require_POST +@login_required +def evaluate_policy_rules_view(request, dataspace, name, version=""): + guarded_qs = Product.objects.get_queryset(request.user, perms="change_product") + product = get_object_or_404( + guarded_qs, + name=unquote_plus(name), + version=unquote_plus(version), + dataspace__name=dataspace, + ) + + evaluate_rules(product) + + return HttpResponse(headers={"HX-Refresh": "true"}) + + @login_required def import_from_scan_view(request, dataspace, name, version=""): """ @@ -2771,6 +2788,7 @@ def get_context_data(self, **kwargs): product = self.object productpackages = product.productpackages.all() licenses = License.objects.filter(productpackage__in=productpackages) + user_perms = guardian_get_perms(self.request.user, product) context.update( { @@ -2778,6 +2796,7 @@ def get_context_data(self, **kwargs): **self.get_license_compliance_context(licenses), **self.get_security_compliance_context(product), **self.get_policy_compliance_context(product), + "has_change_permission": "change_product" in user_perms, } ) From cb94f975194841f161ea3fa2588ef401141f8800 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 16:43:32 +0400 Subject: [PATCH 34/54] plug signals to evaluate rules Signed-off-by: tdruez --- policy/signals.py | 25 +++++++++++++++++++++++-- policy/tasks.py | 11 ++++++++--- product_portfolio/models.py | 4 ---- product_portfolio/views.py | 6 +++--- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/policy/signals.py b/policy/signals.py index 17f0d948..72256570 100644 --- a/policy/signals.py +++ b/policy/signals.py @@ -8,16 +8,37 @@ import logging +from django.db.models.signals import post_delete from django.db.models.signals import post_save from django.dispatch import receiver +from policy.tasks import evaluate_all_products_rules_task from policy.tasks import evaluate_product_rules_task logger = logging.getLogger(__name__) @receiver(post_save, sender="product_portfolio.Product") -def evaluate_product_rules_on_save(sender, instance, **kwargs): +def evaluate_product_rules_on_product_save(sender, instance, **kwargs): """Queue a policy rule evaluation whenever a product is saved.""" - logger.debug(f"Queuing policy rule evaluation for product {instance.uuid}") evaluate_product_rules_task.delay(product_uuid=instance.uuid) + + +@receiver(post_save, sender="product_portfolio.ProductPackage") +def evaluate_product_rules_on_productpackage_save(sender, instance, **kwargs): + """Queue a policy rule evaluation whenever a package is added or updated in a product.""" + evaluate_product_rules_task.delay(product_uuid=instance.product.uuid) + + +@receiver(post_delete, sender="product_portfolio.ProductPackage") +def evaluate_product_rules_on_productpackage_delete(sender, instance, **kwargs): + """Queue a policy rule evaluation whenever a package is removed from a product.""" + evaluate_product_rules_task.delay(product_uuid=instance.product.uuid) + + +@receiver(post_save, sender="component_catalog.Package") +def evaluate_product_rules_on_package_save(sender, instance, **kwargs): + """Queue a policy rule evaluation for all products containing this package.""" + product_uuids = list(instance.productpackages.values_list("product__uuid", flat=True)) + if product_uuids: + evaluate_all_products_rules_task.delay(product_uuids=product_uuids) diff --git a/policy/tasks.py b/policy/tasks.py index 237b3796..381196e8 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -59,12 +59,17 @@ def evaluate_product_rules_task(product_uuid): @job -def evaluate_all_products_rules_task(include_locked=False): - """Evaluate policy rules for every product directly, skipping locked ones by default.""" +def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): + """ + Evaluate policy rules for products, skipping locked ones by default. + When product_uuids is provided, only those products are evaluated. + """ Product = apps.get_model("product_portfolio", "product") products = get_unsecured_manager(Product).select_related("dataspace") - if not include_locked: + if product_uuids is not None: + products = products.filter(uuid__in=product_uuids) + elif not include_locked: products = products.exclude_locked() count = products.count() diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 0ba9353f..550623dc 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -442,10 +442,6 @@ def save(self, *args, **kwargs): if self.has_changed("configuration_status_id"): self.actions_on_status_change() - from policy.engine import evaluate_rules - - evaluate_rules(product=self) - def get_attribution_url(self): return self.get_url("attribution") diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 7441b834..e6dd7173 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -112,6 +112,9 @@ from license_library.filters import LicenseFilterSet from license_library.models import License from license_library.models import LicenseAssignedTag +from policy.engine import evaluate_rules +from policy.engine import get_effective_config +from policy.rules import RULE_REGISTRY from product_portfolio.filters import CodebaseResourceFilterSet from product_portfolio.filters import DependencyFilterSet from product_portfolio.filters import ProductComponentFilterSet @@ -143,9 +146,6 @@ from product_portfolio.models import ScanCodeProject from product_portfolio.tasks import improve_packages_from_purldb_task from product_portfolio.tasks import pull_project_data_from_scancodeio_task -from policy.engine import evaluate_rules -from policy.engine import get_effective_config -from policy.rules import RULE_REGISTRY from vulnerabilities.forms import VulnerabilityAnalysisForm from vulnerabilities.models import AffectedByVulnerabilityMixin from vulnerabilities.models import Vulnerability From 1b6e41252f1388054f2a380db91880577709f51a Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 16:53:29 +0400 Subject: [PATCH 35/54] refine evaluate_product_rules_on_package_save Signed-off-by: tdruez --- policy/signals.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policy/signals.py b/policy/signals.py index 72256570..e0060f8f 100644 --- a/policy/signals.py +++ b/policy/signals.py @@ -37,8 +37,11 @@ def evaluate_product_rules_on_productpackage_delete(sender, instance, **kwargs): @receiver(post_save, sender="component_catalog.Package") -def evaluate_product_rules_on_package_save(sender, instance, **kwargs): +def evaluate_product_rules_on_package_save(sender, instance, created, **kwargs): """Queue a policy rule evaluation for all products containing this package.""" + if created: + return # A newly created package has no ProductPackage relations yet. + product_uuids = list(instance.productpackages.values_list("product__uuid", flat=True)) if product_uuids: evaluate_all_products_rules_task.delay(product_uuids=product_uuids) From 4285ca7d3df371c985028a53ba68585b45a38c74 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 17:20:21 +0400 Subject: [PATCH 36/54] fix tasks bug and add unit tests Signed-off-by: tdruez --- notification/tasks.py | 3 +- notification/tests/test_tasks.py | 42 +++++++++ policy/tasks.py | 2 +- policy/tests/test_tasks.py | 149 +++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 policy/tests/test_tasks.py diff --git a/notification/tasks.py b/notification/tasks.py index 7c42c97e..e8222e33 100644 --- a/notification/tasks.py +++ b/notification/tasks.py @@ -12,6 +12,7 @@ from django_rq import job +from dje.models import get_unsecured_manager from notification.models import WebhookSubscription logger = logging.getLogger("dje") @@ -36,7 +37,7 @@ def deliver_webhook_task( if instance_app_label and instance_model_name and instance_pk: try: model_class = apps.get_model(instance_app_label, instance_model_name) - instance = model_class.objects.get(pk=instance_pk) + instance = get_unsecured_manager(model_class).get(pk=instance_pk) except Exception: logger.error( f"Instance {instance_app_label}.{instance_model_name} pk={instance_pk} not found." diff --git a/notification/tests/test_tasks.py b/notification/tests/test_tasks.py index 6ee03ad3..ce0227ec 100644 --- a/notification/tests/test_tasks.py +++ b/notification/tests/test_tasks.py @@ -7,6 +7,7 @@ # import json +import uuid from unittest.mock import patch from django.conf import settings @@ -16,6 +17,8 @@ from dje.models import Dataspace from dje.tests import create_superuser from notification.models import WebhookSubscription +from notification.tasks import deliver_webhook_task +from product_portfolio.tests import make_product from workflow.models import Priority from workflow.models import Question from workflow.models import Request @@ -23,6 +26,45 @@ from workflow.models import RequestTemplate +class DeliverWebhookTaskTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.webhook = WebhookSubscription.objects.create( + dataspace=self.dataspace, + target_url="http://127.0.0.1:8000/", + event="policy.violation_detected", + ) + + @patch("notification.models.WebhookSubscription.deliver") + def test_deliver_webhook_task_resolves_secured_model_instance(self, mock_deliver): + product = make_product(self.dataspace) + deliver_webhook_task( + webhook_subscription_uuid=self.webhook.uuid, + payload_override={"text": "test"}, + instance_app_label="product_portfolio", + instance_model_name="product", + instance_pk=product.pk, + ) + mock_deliver.assert_called_once() + delivered_instance = mock_deliver.call_args[0][0] + self.assertEqual(product, delivered_instance) + + def test_deliver_webhook_task_missing_subscription_logs_error(self): + with self.assertLogs("dje", level="ERROR") as captured: + deliver_webhook_task(webhook_subscription_uuid=uuid.uuid4()) + self.assertTrue(any("not found" in line for line in captured.output)) + + def test_deliver_webhook_task_missing_instance_logs_error(self): + with self.assertLogs("dje", level="ERROR") as captured: + deliver_webhook_task( + webhook_subscription_uuid=self.webhook.uuid, + instance_app_label="product_portfolio", + instance_model_name="product", + instance_pk=99999999, + ) + self.assertTrue(any("not found" in line for line in captured.output)) + + class NotificationTasksTestCase(TestCase): def setUp(self): self.nexb_dataspace = Dataspace.objects.create(name="nexB") diff --git a/policy/tasks.py b/policy/tasks.py index 381196e8..214b7c6b 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -70,7 +70,7 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): if product_uuids is not None: products = products.filter(uuid__in=product_uuids) elif not include_locked: - products = products.exclude_locked() + products = products.exclude(configuration_status__is_locked=True) count = products.count() logger.info(f"Starting policy rule evaluation for {count} product(s).") diff --git a/policy/tests/test_tasks.py b/policy/tests/test_tasks.py new file mode 100644 index 00000000..31f7c6ed --- /dev/null +++ b/policy/tests/test_tasks.py @@ -0,0 +1,149 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +import uuid +from unittest.mock import MagicMock +from unittest.mock import patch + +from django.test import TestCase + +from dje.models import Dataspace +from policy.tasks import evaluate_all_products_rules_task +from policy.tasks import evaluate_product_rules_task +from policy.tasks import fire_policy_webhooks +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_status + + +class FirePolicyWebhooksTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("policy.tasks.fire_webhooks") + def test_fire_policy_webhooks_dispatches_violation_detected(self, mock_fire): + violation = MagicMock() + violation.rule_label = "Usage Policy Error" + violation.violation_count = 3 + fire_policy_webhooks(self.product, new_violations=[violation], resolved_count=0) + mock_fire.assert_called_once() + event_name, kwargs = mock_fire.call_args[0][0], mock_fire.call_args[1] + self.assertEqual("policy.violation_detected", event_name) + self.assertIn("Policy violations detected", kwargs["payload_override"]["text"]) + self.assertIn("Usage Policy Error", kwargs["payload_override"]["text"]) + + @patch("policy.tasks.fire_webhooks") + def test_fire_policy_webhooks_dispatches_violation_resolved(self, mock_fire): + fire_policy_webhooks(self.product, new_violations=[], resolved_count=2) + mock_fire.assert_called_once() + event_name, kwargs = mock_fire.call_args[0][0], mock_fire.call_args[1] + self.assertEqual("policy.violation_resolved", event_name) + self.assertIn("2 policy violation(s) resolved", kwargs["payload_override"]["text"]) + + @patch("policy.tasks.fire_webhooks") + def test_fire_policy_webhooks_dispatches_both_events(self, mock_fire): + violation = MagicMock() + violation.rule_label = "License Coverage Gap" + violation.violation_count = 1 + fire_policy_webhooks(self.product, new_violations=[violation], resolved_count=1) + self.assertEqual(2, mock_fire.call_count) + events_fired = [c[0][0] for c in mock_fire.call_args_list] + self.assertIn("policy.violation_detected", events_fired) + self.assertIn("policy.violation_resolved", events_fired) + + @patch("policy.tasks.fire_webhooks") + def test_fire_policy_webhooks_silent_when_no_changes(self, mock_fire): + fire_policy_webhooks(self.product, new_violations=[], resolved_count=0) + mock_fire.assert_not_called() + + +class EvaluateProductRulesTaskTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("policy.tasks.fire_policy_webhooks") + @patch("policy.tasks.evaluate_rules") + def test_evaluate_product_rules_task_runs_evaluation(self, mock_evaluate, mock_fire): + mock_evaluate.return_value = ([], 0) + evaluate_product_rules_task(product_uuid=self.product.uuid) + mock_evaluate.assert_called_once_with(self.product) + mock_fire.assert_called_once_with(self.product, [], 0) + + @patch("policy.tasks.fire_policy_webhooks") + @patch("policy.tasks.evaluate_rules") + def test_evaluate_product_rules_task_unknown_uuid_logs_error(self, mock_evaluate, mock_fire): + with self.assertLogs("policy.tasks", level="ERROR") as captured: + evaluate_product_rules_task(product_uuid=uuid.uuid4()) + mock_evaluate.assert_not_called() + mock_fire.assert_not_called() + self.assertTrue(any("not found" in line for line in captured.output)) + + +class EvaluateAllProductsRulesTaskTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + @patch("policy.tasks.fire_policy_webhooks") + @patch("policy.tasks.evaluate_rules") + def test_evaluate_all_products_excludes_locked_by_default(self, mock_evaluate, mock_fire): + # Regression: previously called .exclude_locked() on DataspacedQuerySet which lacks that + # method. Now uses .exclude(configuration_status__is_locked=True) inline. + mock_evaluate.return_value = ([], 0) + active_product = make_product(self.dataspace) + locked_status = make_product_status(self.dataspace, is_locked=True) + locked_product = make_product(self.dataspace, configuration_status=locked_status) + mock_evaluate.reset_mock() + + evaluate_all_products_rules_task() + + evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list] + self.assertIn(active_product, evaluated_products) + self.assertNotIn(locked_product, evaluated_products) + + @patch("policy.tasks.fire_policy_webhooks") + @patch("policy.tasks.evaluate_rules") + def test_evaluate_all_products_includes_locked_when_requested(self, mock_evaluate, mock_fire): + mock_evaluate.return_value = ([], 0) + locked_status = make_product_status(self.dataspace, is_locked=True) + locked_product = make_product(self.dataspace, configuration_status=locked_status) + mock_evaluate.reset_mock() + + evaluate_all_products_rules_task(include_locked=True) + + evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list] + self.assertIn(locked_product, evaluated_products) + + @patch("policy.tasks.fire_policy_webhooks") + @patch("policy.tasks.evaluate_rules") + def test_evaluate_all_products_filters_by_uuids(self, mock_evaluate, mock_fire): + mock_evaluate.return_value = ([], 0) + product_a = make_product(self.dataspace) + product_b = make_product(self.dataspace) + mock_evaluate.reset_mock() + + evaluate_all_products_rules_task(product_uuids=[product_a.uuid]) + + evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list] + self.assertIn(product_a, evaluated_products) + self.assertNotIn(product_b, evaluated_products) + + @patch("policy.tasks.fire_policy_webhooks") + @patch("policy.tasks.evaluate_rules") + def test_evaluate_all_products_uuid_filter_ignores_locked_exclusion( + self, mock_evaluate, mock_fire + ): + mock_evaluate.return_value = ([], 0) + locked_status = make_product_status(self.dataspace, is_locked=True) + locked_product = make_product(self.dataspace, configuration_status=locked_status) + mock_evaluate.reset_mock() + + evaluate_all_products_rules_task(product_uuids=[locked_product.uuid]) + + evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list] + self.assertIn(locked_product, evaluated_products) From 4e1762c3957f085a022878a95e3480158f6257b4 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Jul 2026 18:08:42 +0400 Subject: [PATCH 37/54] add REST API action for product policy violations Signed-off-by: tdruez --- product_portfolio/api.py | 28 +++++++++++++++++++ product_portfolio/tests/test_api.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/product_portfolio/api.py b/product_portfolio/api.py index c8d9998f..af6602b4 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -48,6 +48,7 @@ from product_portfolio.models import ProductComponent from product_portfolio.models import ProductDependency from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPolicyViolation from product_portfolio.models import ScanCodeProject from vulnerabilities.api import VulnerabilityAnalysisSerializer @@ -343,6 +344,25 @@ class Meta: ) +class ProductPolicyViolationSerializer(serializers.ModelSerializer): + rule_label = serializers.ReadOnlyField() + rule_description = serializers.ReadOnlyField() + rule_severity = serializers.ReadOnlyField() + + class Meta: + model = ProductPolicyViolation + fields = ( + "rule_type", + "rule_label", + "rule_description", + "rule_severity", + "violation_count", + "detected_date", + "resolved", + "resolved_date", + ) + + class ProductViewSet( ObjectPermissionsMixin, SendAboutFilesMixin, @@ -413,6 +433,14 @@ def imports(self, request, uuid): projects_data = ScanCodeProjectSerializer(scancode_projects, many=True).data return Response(projects_data) + @action(detail=True, url_path="policy_violations") + def policy_violations(self, request, uuid): + """List active policy violations for this product, with rule details and counts.""" + product = self.get_object() + violations = product.policy_violations.filter(resolved=False) + serializer = ProductPolicyViolationSerializer(violations, many=True) + return Response(serializer.data) + @action(detail=True, methods=["post"], serializer_class=LoadSBOMsFormSerializer) def load_sboms(self, request, *args, **kwargs): """ diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index ce43a34e..051b490f 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -43,6 +43,7 @@ from product_portfolio.models import ProductComponent from product_portfolio.models import ProductItemPurpose from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPolicyViolation from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductStatus from product_portfolio.models import ScanCodeProject @@ -717,6 +718,48 @@ def test_api_product_endpoint_manage_permissions_action(self): self.assertIn("errors", response.data) + def test_api_product_endpoint_policy_violations_action(self): + url = reverse("api_v2:product-policy-violations", args=[self.product1.uuid]) + + self.client.login(username=self.base_user.username, password="secret") + response = self.client.get(url) + self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) + + add_perm(self.base_user, "add_product") + assign_perm("view_product", self.base_user, self.product1) + + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual([], response.data) + + violation = ProductPolicyViolation.objects.create( + product=self.product1, + dataspace=self.dataspace, + rule_type="usage_policy_error", + violation_count=5, + ) + + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(1, len(response.data)) + entry = response.data[0] + self.assertEqual("usage_policy_error", entry["rule_type"]) + self.assertEqual(5, entry["violation_count"]) + self.assertFalse(entry["resolved"]) + self.assertIsNone(entry["resolved_date"]) + self.assertIn("rule_label", entry) + self.assertIn("rule_description", entry) + self.assertIn("rule_severity", entry) + self.assertIn("detected_date", entry) + + violation.resolved = True + violation.save() + + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual([], response.data) + + class ProductRelatedAPITestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") From 56736555bb3f5e997d692d0fe1f49085e7c2a121 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 12:50:06 +0400 Subject: [PATCH 38/54] fix bug in rule evaluation when previously raised then closed Signed-off-by: tdruez --- policy/engine.py | 18 ++++--- policy/tests/test_engine.py | 73 +++++++++++++++++++++++++++++ product_portfolio/tests/test_api.py | 1 - 3 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 policy/tests/test_engine.py diff --git a/policy/engine.py b/policy/engine.py index 142b005b..92d54460 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -41,19 +41,23 @@ def evaluate_rule(rule_type, product, threshold, parameters): handler = RULE_REGISTRY[rule_type] violation_count = handler.count_violations(product, threshold, parameters) - lookup = {"rule_type": rule_type, "product": product, "resolved": False} + lookup = {"rule_type": rule_type, "product": product} if violation_count > 0: - violation, created = ProductPolicyViolation.objects.get_or_create( + # update_or_create on the unique (rule_type, product) pair so that a previously + # resolved violation can be re-activated without hitting the unique constraint. + violation, created = ProductPolicyViolation.objects.update_or_create( **lookup, - defaults={"dataspace": product.dataspace, "violation_count": violation_count}, + defaults={ + "dataspace": product.dataspace, + "violation_count": violation_count, + "resolved": False, + "resolved_date": None, + }, ) - if not created: - violation.violation_count = violation_count - violation.save() return violation, created, 0 - resolved_count = ProductPolicyViolation.objects.filter(**lookup).update( + resolved_count = ProductPolicyViolation.objects.filter(**lookup, resolved=False).update( resolved=True, resolved_date=timezone.now(), ) diff --git a/policy/tests/test_engine.py b/policy/tests/test_engine.py new file mode 100644 index 00000000..6ba85330 --- /dev/null +++ b/policy/tests/test_engine.py @@ -0,0 +1,73 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from unittest.mock import patch + +from django.test import TestCase + +from dje.models import Dataspace +from policy.engine import evaluate_rule +from product_portfolio.models import ProductPolicyViolation +from product_portfolio.tests import make_product + +RULE_TYPE = "usage_policy_error" + + +class EvaluateRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def _count_violations(self, count): + """Patch count_violations for RULE_TYPE to return a fixed count.""" + return patch( + "policy.rules.UsagePolicyErrorRule.count_violations", + return_value=count, + ) + + def test_evaluate_rule_creates_violation_on_first_trigger(self): + with self._count_violations(3): + violation, created, resolved_count = evaluate_rule(RULE_TYPE, self.product, 0, {}) + self.assertTrue(created) + self.assertEqual(0, resolved_count) + self.assertEqual(3, violation.violation_count) + self.assertFalse(violation.resolved) + self.assertEqual(1, ProductPolicyViolation.objects.count()) + + def test_evaluate_rule_resolves_violation_when_count_drops_to_zero(self): + with self._count_violations(3): + evaluate_rule(RULE_TYPE, self.product, 0, {}) + with self._count_violations(0): + violation, created, resolved_count = evaluate_rule(RULE_TYPE, self.product, 0, {}) + self.assertIsNone(violation) + self.assertFalse(created) + self.assertEqual(1, resolved_count) + db_violation = ProductPolicyViolation.objects.get() + self.assertTrue(db_violation.resolved) + self.assertIsNotNone(db_violation.resolved_date) + + def test_evaluate_rule_retrigger_after_resolution_does_not_raise(self): + with self._count_violations(3): + evaluate_rule(RULE_TYPE, self.product, 0, {}) + with self._count_violations(0): + evaluate_rule(RULE_TYPE, self.product, 0, {}) + with self._count_violations(5): + violation, created, resolved_count = evaluate_rule(RULE_TYPE, self.product, 0, {}) + self.assertFalse(violation.resolved) + self.assertIsNone(violation.resolved_date) + self.assertEqual(5, violation.violation_count) + self.assertEqual(1, ProductPolicyViolation.objects.count()) + + def test_evaluate_rule_updates_count_on_existing_active_violation(self): + with self._count_violations(2): + evaluate_rule(RULE_TYPE, self.product, 0, {}) + with self._count_violations(7): + violation, created, resolved_count = evaluate_rule(RULE_TYPE, self.product, 0, {}) + self.assertFalse(created) + self.assertEqual(7, violation.violation_count) + self.assertEqual(1, ProductPolicyViolation.objects.count()) diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 051b490f..23803db8 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -717,7 +717,6 @@ def test_api_product_endpoint_manage_permissions_action(self): self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) self.assertIn("errors", response.data) - def test_api_product_endpoint_policy_violations_action(self): url = reverse("api_v2:product-policy-violations", args=[self.product1.uuid]) From caf8638af2409818dad9982cdaa25ddcb57363e8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:23:40 +0400 Subject: [PATCH 39/54] move fire_policy_webhooks in engine Signed-off-by: tdruez --- policy/engine.py | 21 +++++++++++++++++++++ policy/tasks.py | 22 ---------------------- policy/tests/test_tasks.py | 32 +++++++++++--------------------- 3 files changed, 32 insertions(+), 43 deletions(-) diff --git a/policy/engine.py b/policy/engine.py index 92d54460..eff27d9e 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -8,10 +8,30 @@ from django.utils import timezone +from notification.models import fire_webhooks from policy.rules import RULE_REGISTRY from product_portfolio.models import ProductPolicyViolation +def fire_policy_webhooks(product, new_violations, resolved_count): + """Fire policy webhooks for newly detected or resolved violations.""" + if new_violations: + lines = [ + f"- {violation.rule_label}: {violation.violation_count} violation(s)" + for violation in new_violations + ] + payload = { + "text": (f"[DejaCode] Policy violations detected for {product}\n" + "\n".join(lines)) + } + fire_webhooks("policy.violation_detected", instance=product, payload_override=payload) + + if resolved_count: + payload = { + "text": (f"[DejaCode] {resolved_count} policy violation(s) resolved for {product}") + } + fire_webhooks("policy.violation_resolved", instance=product, payload_override=payload) + + def get_effective_config(rule_type, dataspace): """ Resolve threshold, parameters, and is_active for a rule type in a given dataspace. @@ -95,4 +115,5 @@ def evaluate_rules(product): new_violations.append(violation) resolved_count += resolved + fire_policy_webhooks(product, new_violations, resolved_count) return new_violations, resolved_count diff --git a/policy/tasks.py b/policy/tasks.py index 214b7c6b..4118d54f 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -13,31 +13,11 @@ from django_rq import job from dje.models import get_unsecured_manager -from notification.models import fire_webhooks from policy.engine import evaluate_rules logger = logging.getLogger(__name__) -def fire_policy_webhooks(product, new_violations, resolved_count): - """Fire policy webhooks for newly detected or resolved violations.""" - if new_violations: - lines = [ - f"- {violation.rule_label}: {violation.violation_count} violation(s)" - for violation in new_violations - ] - payload = { - "text": (f"[DejaCode] Policy violations detected for {product}\n" + "\n".join(lines)) - } - fire_webhooks("policy.violation_detected", instance=product, payload_override=payload) - - if resolved_count: - payload = { - "text": (f"[DejaCode] {resolved_count} policy violation(s) resolved for {product}") - } - fire_webhooks("policy.violation_resolved", instance=product, payload_override=payload) - - @job def evaluate_product_rules_task(product_uuid): """Evaluate all active policy rules for the given product and fire webhooks on changes.""" @@ -55,7 +35,6 @@ def evaluate_product_rules_task(product_uuid): f"Policy rules evaluated for {product}: " f"{len(new_violations)} new violation(s), {resolved_count} resolved." ) - fire_policy_webhooks(product, new_violations, resolved_count) @job @@ -82,6 +61,5 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): f"Policy rules evaluated for {product}: " f"{len(new_violations)} new violation(s), {resolved_count} resolved." ) - fire_policy_webhooks(product, new_violations, resolved_count) logger.info(f"Policy rule evaluation complete for {count} product(s).") diff --git a/policy/tests/test_tasks.py b/policy/tests/test_tasks.py index 31f7c6ed..fa8749c7 100644 --- a/policy/tests/test_tasks.py +++ b/policy/tests/test_tasks.py @@ -13,9 +13,9 @@ from django.test import TestCase from dje.models import Dataspace +from policy.engine import fire_policy_webhooks from policy.tasks import evaluate_all_products_rules_task from policy.tasks import evaluate_product_rules_task -from policy.tasks import fire_policy_webhooks from product_portfolio.tests import make_product from product_portfolio.tests import make_product_status @@ -25,7 +25,7 @@ def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") self.product = make_product(self.dataspace) - @patch("policy.tasks.fire_webhooks") + @patch("policy.engine.fire_webhooks") def test_fire_policy_webhooks_dispatches_violation_detected(self, mock_fire): violation = MagicMock() violation.rule_label = "Usage Policy Error" @@ -37,7 +37,7 @@ def test_fire_policy_webhooks_dispatches_violation_detected(self, mock_fire): self.assertIn("Policy violations detected", kwargs["payload_override"]["text"]) self.assertIn("Usage Policy Error", kwargs["payload_override"]["text"]) - @patch("policy.tasks.fire_webhooks") + @patch("policy.engine.fire_webhooks") def test_fire_policy_webhooks_dispatches_violation_resolved(self, mock_fire): fire_policy_webhooks(self.product, new_violations=[], resolved_count=2) mock_fire.assert_called_once() @@ -45,7 +45,7 @@ def test_fire_policy_webhooks_dispatches_violation_resolved(self, mock_fire): self.assertEqual("policy.violation_resolved", event_name) self.assertIn("2 policy violation(s) resolved", kwargs["payload_override"]["text"]) - @patch("policy.tasks.fire_webhooks") + @patch("policy.engine.fire_webhooks") def test_fire_policy_webhooks_dispatches_both_events(self, mock_fire): violation = MagicMock() violation.rule_label = "License Coverage Gap" @@ -56,7 +56,7 @@ def test_fire_policy_webhooks_dispatches_both_events(self, mock_fire): self.assertIn("policy.violation_detected", events_fired) self.assertIn("policy.violation_resolved", events_fired) - @patch("policy.tasks.fire_webhooks") + @patch("policy.engine.fire_webhooks") def test_fire_policy_webhooks_silent_when_no_changes(self, mock_fire): fire_policy_webhooks(self.product, new_violations=[], resolved_count=0) mock_fire.assert_not_called() @@ -67,21 +67,17 @@ def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") self.product = make_product(self.dataspace) - @patch("policy.tasks.fire_policy_webhooks") @patch("policy.tasks.evaluate_rules") - def test_evaluate_product_rules_task_runs_evaluation(self, mock_evaluate, mock_fire): + def test_evaluate_product_rules_task_runs_evaluation(self, mock_evaluate): mock_evaluate.return_value = ([], 0) evaluate_product_rules_task(product_uuid=self.product.uuid) mock_evaluate.assert_called_once_with(self.product) - mock_fire.assert_called_once_with(self.product, [], 0) - @patch("policy.tasks.fire_policy_webhooks") @patch("policy.tasks.evaluate_rules") - def test_evaluate_product_rules_task_unknown_uuid_logs_error(self, mock_evaluate, mock_fire): + def test_evaluate_product_rules_task_unknown_uuid_logs_error(self, mock_evaluate): with self.assertLogs("policy.tasks", level="ERROR") as captured: evaluate_product_rules_task(product_uuid=uuid.uuid4()) mock_evaluate.assert_not_called() - mock_fire.assert_not_called() self.assertTrue(any("not found" in line for line in captured.output)) @@ -89,9 +85,8 @@ class EvaluateAllProductsRulesTaskTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") - @patch("policy.tasks.fire_policy_webhooks") @patch("policy.tasks.evaluate_rules") - def test_evaluate_all_products_excludes_locked_by_default(self, mock_evaluate, mock_fire): + def test_evaluate_all_products_excludes_locked_by_default(self, mock_evaluate): # Regression: previously called .exclude_locked() on DataspacedQuerySet which lacks that # method. Now uses .exclude(configuration_status__is_locked=True) inline. mock_evaluate.return_value = ([], 0) @@ -106,9 +101,8 @@ def test_evaluate_all_products_excludes_locked_by_default(self, mock_evaluate, m self.assertIn(active_product, evaluated_products) self.assertNotIn(locked_product, evaluated_products) - @patch("policy.tasks.fire_policy_webhooks") @patch("policy.tasks.evaluate_rules") - def test_evaluate_all_products_includes_locked_when_requested(self, mock_evaluate, mock_fire): + def test_evaluate_all_products_includes_locked_when_requested(self, mock_evaluate): mock_evaluate.return_value = ([], 0) locked_status = make_product_status(self.dataspace, is_locked=True) locked_product = make_product(self.dataspace, configuration_status=locked_status) @@ -119,9 +113,8 @@ def test_evaluate_all_products_includes_locked_when_requested(self, mock_evaluat evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list] self.assertIn(locked_product, evaluated_products) - @patch("policy.tasks.fire_policy_webhooks") @patch("policy.tasks.evaluate_rules") - def test_evaluate_all_products_filters_by_uuids(self, mock_evaluate, mock_fire): + def test_evaluate_all_products_filters_by_uuids(self, mock_evaluate): mock_evaluate.return_value = ([], 0) product_a = make_product(self.dataspace) product_b = make_product(self.dataspace) @@ -133,11 +126,8 @@ def test_evaluate_all_products_filters_by_uuids(self, mock_evaluate, mock_fire): self.assertIn(product_a, evaluated_products) self.assertNotIn(product_b, evaluated_products) - @patch("policy.tasks.fire_policy_webhooks") @patch("policy.tasks.evaluate_rules") - def test_evaluate_all_products_uuid_filter_ignores_locked_exclusion( - self, mock_evaluate, mock_fire - ): + def test_evaluate_all_products_uuid_filter_ignores_locked_exclusion(self, mock_evaluate): mock_evaluate.return_value = ([], 0) locked_status = make_product_status(self.dataspace, is_locked=True) locked_product = make_product(self.dataspace, configuration_status=locked_status) From 307a3df01831c37ac838e037efb9754020011aac Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:27:39 +0400 Subject: [PATCH 40/54] set last_checked Signed-off-by: tdruez --- policy/engine.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policy/engine.py b/policy/engine.py index eff27d9e..faca8bfb 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -77,9 +77,11 @@ def evaluate_rule(rule_type, product, threshold, parameters): ) return violation, created, 0 + now = timezone.now() resolved_count = ProductPolicyViolation.objects.filter(**lookup, resolved=False).update( resolved=True, - resolved_date=timezone.now(), + resolved_date=now, + last_checked=now, ) return None, False, resolved_count From 8456526db05a1382f714483836721b07c4537847 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:27:55 +0400 Subject: [PATCH 41/54] move import Signed-off-by: tdruez --- product_portfolio/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py index 7e9a9f6f..9adbc4d5 100644 --- a/product_portfolio/admin.py +++ b/product_portfolio/admin.py @@ -46,6 +46,7 @@ from dje.permissions import assign_all_object_permissions from dje.permissions import get_limited_perms_for_model from dje.utils import is_purl_fragment +from policy.tasks import evaluate_product_rules_task from product_portfolio.filters import ComponentCompletenessListFilter from product_portfolio.forms import ProductAdminForm from product_portfolio.forms import ProductComponentAdminForm @@ -396,8 +397,6 @@ class ProductAdmin( readonly_fields = DataspacedAdmin.readonly_fields + ("get_feature_datalist",) def evaluate_policy_rules(self, request, queryset): - from policy.tasks import evaluate_product_rules_task - count = queryset.count() for product in queryset: evaluate_product_rules_task.delay(product_uuid=product.uuid) From d4d81ba6cf6678492cb39c2e12838a0c3c0fbaf5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:30:07 +0400 Subject: [PATCH 42/54] optimization Signed-off-by: tdruez --- product_portfolio/models.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 550623dc..fc3c506c 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -1954,17 +1954,18 @@ class Meta: def __str__(self): return f"{self.rule_type} / {self.product}: {self.violation_count} violation(s)" + @cached_property + def rule_handler(self): + return RULE_REGISTRY.get(self.rule_type) + @property def rule_label(self): - handler = RULE_REGISTRY.get(self.rule_type) - return handler.label if handler else self.rule_type + return self.rule_handler.label if self.rule_handler else self.rule_type @property def rule_description(self): - handler = RULE_REGISTRY.get(self.rule_type) - return handler.description if handler else "" + return self.rule_handler.description if self.rule_handler else "" @property def rule_severity(self): - handler = RULE_REGISTRY.get(self.rule_type) - return handler.severity if handler else "warning" + return self.rule_handler.severity if self.rule_handler else "warning" From 887e2c6a55882bee7975cb5f47b5228a9e301491 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:30:24 +0400 Subject: [PATCH 43/54] change exclusion logic Signed-off-by: tdruez --- policy/tasks.py | 2 +- policy/tests/test_tasks.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policy/tasks.py b/policy/tasks.py index 4118d54f..71e714aa 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -48,7 +48,7 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): products = get_unsecured_manager(Product).select_related("dataspace") if product_uuids is not None: products = products.filter(uuid__in=product_uuids) - elif not include_locked: + if not include_locked: products = products.exclude(configuration_status__is_locked=True) count = products.count() diff --git a/policy/tests/test_tasks.py b/policy/tests/test_tasks.py index fa8749c7..dcc470e7 100644 --- a/policy/tests/test_tasks.py +++ b/policy/tests/test_tasks.py @@ -127,7 +127,7 @@ def test_evaluate_all_products_filters_by_uuids(self, mock_evaluate): self.assertNotIn(product_b, evaluated_products) @patch("policy.tasks.evaluate_rules") - def test_evaluate_all_products_uuid_filter_ignores_locked_exclusion(self, mock_evaluate): + def test_evaluate_all_products_uuid_filter_still_excludes_locked(self, mock_evaluate): mock_evaluate.return_value = ([], 0) locked_status = make_product_status(self.dataspace, is_locked=True) locked_product = make_product(self.dataspace, configuration_status=locked_status) @@ -136,4 +136,4 @@ def test_evaluate_all_products_uuid_filter_ignores_locked_exclusion(self, mock_e evaluate_all_products_rules_task(product_uuids=[locked_product.uuid]) evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list] - self.assertIn(locked_product, evaluated_products) + self.assertNotIn(locked_product, evaluated_products) From a14a916460f03b13602b626298129d63e817ee0e Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:32:55 +0400 Subject: [PATCH 44/54] continue in case of evaluation error Signed-off-by: tdruez --- policy/tasks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policy/tasks.py b/policy/tasks.py index 71e714aa..4cabfb2c 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -56,7 +56,11 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): for product in products: logger.info(f"Evaluating policy rules for product {product}") - new_violations, resolved_count = evaluate_rules(product) + try: + new_violations, resolved_count = evaluate_rules(product) + except Exception: + logger.exception(f"Policy rule evaluation failed for product {product}, skipping.") + continue logger.info( f"Policy rules evaluated for {product}: " f"{len(new_violations)} new violation(s), {resolved_count} resolved." From dd3c053e5f92ce6fd6798d05d861aab976f5f221 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 13:37:22 +0400 Subject: [PATCH 45/54] query optimization Signed-off-by: tdruez --- policy/tasks.py | 2 +- product_portfolio/views.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/policy/tasks.py b/policy/tasks.py index 4cabfb2c..bcedb5e0 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -45,7 +45,7 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): """ Product = apps.get_model("product_portfolio", "product") - products = get_unsecured_manager(Product).select_related("dataspace") + products = get_unsecured_manager(Product).select_related("dataspace__configuration") if product_uuids is not None: products = products.filter(uuid__in=product_uuids) if not include_locked: diff --git a/product_portfolio/views.py b/product_portfolio/views.py index e6dd7173..ea091e61 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2869,7 +2869,9 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): - policy_violations = product.policy_violations.filter(resolved=False).order_by("rule_type") + policy_violations = list( + product.policy_violations.filter(resolved=False).order_by("rule_type") + ) violated_rule_types = {violation.rule_type for violation in policy_violations} all_rules = [ { @@ -2884,7 +2886,7 @@ def get_policy_compliance_context(product): ] return { "policy_violations": policy_violations, - "policy_violation_count": policy_violations.count(), + "policy_violation_count": len(policy_violations), "all_rules": all_rules, } From 3199be9d2b539334d461cc66cc36d1d82f053561 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 14:15:59 +0400 Subject: [PATCH 46/54] remove the vulnerability related rule Signed-off-by: tdruez --- policy/rules.py | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/policy/rules.py b/policy/rules.py index 94d56a15..bc89931c 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -100,39 +100,10 @@ class LicenseCoverageGapRule(PackageBaseRule): package_filter = {"license_expression": ""} -class VulnerabilityDetectedRule(BaseRule): - rule_type = "vulnerability_detected" - label = "Vulnerability Detected" - severity = "error" - description = "Detects packages with at least one known vulnerability (non-null risk score)." - parameters_schema = { - "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.", - } - - def get_package_filter(self): - return {"package__risk_score__isnull": False} - - def count_violations(self, product, threshold, parameters): - Package = apps.get_model("component_catalog", "package") - - packages = Package.objects.filter( - productpackages__product=product, - risk_score__isnull=False, - ) - - min_risk_score = parameters.get("min_risk_score") - if min_risk_score is not None: - packages = packages.filter(risk_score__gte=min_risk_score) - - count = packages.count() - return count if count > threshold else 0 - - RULE_REGISTRY = { UsagePolicyErrorRule.rule_type: UsagePolicyErrorRule(), UsagePolicyWarningRule.rule_type: UsagePolicyWarningRule(), LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(), LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(), LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(), - VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(), } From 9f3a432ec60778f58032b8050b23bc4b9d4f942b Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 14:29:39 +0400 Subject: [PATCH 47/54] migrate to rules opt in in place of opt out Signed-off-by: tdruez --- dje/admin.py | 12 ++++++------ dje/tests/test_admin.py | 2 ++ dje/tests/test_history.py | 2 ++ policy/engine.py | 2 +- product_portfolio/views.py | 4 +++- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/dje/admin.py b/dje/admin.py index 02d1c9f3..4cdce6c1 100644 --- a/dje/admin.py +++ b/dje/admin.py @@ -1094,10 +1094,10 @@ def add_policy_rule_config_fields(self): config = getattr(self.instance, "policy_rules_config", {}) or {} for rule_type, handler in RULE_REGISTRY.items(): rule_config = config.get(rule_type, {}) - self.fields[f"rule_{rule_type}_disabled"] = forms.BooleanField( - label="Disable this rule", + self.fields[f"rule_{rule_type}_enabled"] = forms.BooleanField( + label="Enable this rule", required=False, - initial=not rule_config.get("is_active", True), + initial=rule_config.get("is_active", False), ) self.fields[f"rule_{rule_type}_threshold"] = forms.IntegerField( label="Threshold", @@ -1122,8 +1122,8 @@ def build_policy_rules_config(self): policy_rules_config = {} for rule_type, handler in RULE_REGISTRY.items(): rule_config = {} - if self.cleaned_data.get(f"rule_{rule_type}_disabled"): - rule_config["is_active"] = False + if self.cleaned_data.get(f"rule_{rule_type}_enabled"): + rule_config["is_active"] = True threshold = self.cleaned_data.get(f"rule_{rule_type}_threshold") if threshold is not None: rule_config["threshold"] = threshold @@ -1242,7 +1242,7 @@ def get_fieldsets(self, request, obj=None): return [] rule_fieldsets = [] for rule_type, handler in RULE_REGISTRY.items(): - fields = [f"rule_{rule_type}_disabled", f"rule_{rule_type}_threshold"] + fields = [f"rule_{rule_type}_enabled", f"rule_{rule_type}_threshold"] for param_name in handler.parameters_schema: fields.append(f"rule_{rule_type}_param_{param_name}") rule_fieldsets.append( diff --git a/dje/tests/test_admin.py b/dje/tests/test_admin.py index 83e00311..8b4c2ca4 100644 --- a/dje/tests/test_admin.py +++ b/dje/tests/test_admin.py @@ -178,6 +178,8 @@ def test_dataspace_admin_changeform_update_packages_from_scan_field_validation(s "update_packages_from_scan": True, "configuration-TOTAL_FORMS": 0, "configuration-INITIAL_FORMS": 0, + "configuration-2-TOTAL_FORMS": 0, + "configuration-2-INITIAL_FORMS": 0, } response = self.client.post(url, data) diff --git a/dje/tests/test_history.py b/dje/tests/test_history.py index b9182bd2..cbba8700 100644 --- a/dje/tests/test_history.py +++ b/dje/tests/test_history.py @@ -58,6 +58,8 @@ def test_history_on_admin_dataspace_add(self): "name": "new_dataspace", "configuration-TOTAL_FORMS": 0, "configuration-INITIAL_FORMS": 0, + "configuration-2-TOTAL_FORMS": 0, + "configuration-2-INITIAL_FORMS": 0, } self.client.post(url, params) diff --git a/policy/engine.py b/policy/engine.py index faca8bfb..0db4329d 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -46,7 +46,7 @@ def get_effective_config(rule_type, dataspace): rule_config = {} return { - "is_active": rule_config.get("is_active", True), + "is_active": rule_config.get("is_active", False), "threshold": rule_config.get("threshold", handler.default_threshold), "parameters": rule_config.get("parameters", {}), } diff --git a/product_portfolio/views.py b/product_portfolio/views.py index ea091e61..a79e7a68 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2870,7 +2870,9 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): policy_violations = list( - product.policy_violations.filter(resolved=False).order_by("rule_type") + product.policy_violations.filter( + resolved=False, rule_type__in=RULE_REGISTRY + ).order_by("rule_type") ) violated_rule_types = {violation.rule_type for violation in policy_violations} all_rules = [ From 10b989132ffa5027604807a911d5e4dc9b2462cc Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 14:39:28 +0400 Subject: [PATCH 48/54] evaluate rules when chaging dataspace config Signed-off-by: tdruez --- policy/signals.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/policy/signals.py b/policy/signals.py index e0060f8f..4734945b 100644 --- a/policy/signals.py +++ b/policy/signals.py @@ -8,10 +8,12 @@ import logging +from django.apps import apps from django.db.models.signals import post_delete from django.db.models.signals import post_save from django.dispatch import receiver +from dje.models import get_unsecured_manager from policy.tasks import evaluate_all_products_rules_task from policy.tasks import evaluate_product_rules_task @@ -45,3 +47,19 @@ def evaluate_product_rules_on_package_save(sender, instance, created, **kwargs): product_uuids = list(instance.productpackages.values_list("product__uuid", flat=True)) if product_uuids: evaluate_all_products_rules_task.delay(product_uuids=product_uuids) + + +@receiver(post_save, sender="dje.DataspaceConfiguration") +def evaluate_products_on_policy_rules_config_save(sender, instance, update_fields, **kwargs): + """Queue re-evaluation for all products in the dataspace when policy rules config changes.""" + if update_fields is not None and "policy_rules_config" not in update_fields: + return + + Product = apps.get_model("product_portfolio", "product") + product_uuids = list( + get_unsecured_manager(Product) + .filter(dataspace=instance.dataspace) + .values_list("uuid", flat=True) + ) + if product_uuids: + evaluate_all_products_rules_task.delay(product_uuids=product_uuids) From 9a5a26b0ea8591ffaecf2cbd89d92fe60040a6e3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 16:21:27 +0400 Subject: [PATCH 49/54] refine the queryset to include policy violation Signed-off-by: tdruez --- product_portfolio/models.py | 4 +++- product_portfolio/views.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index fc3c506c..f4596e1f 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -219,6 +219,7 @@ def with_compliance_data(self): self.with_risk_threshold() .with_vulnerability_counts() .with_license_compliance_counts() + .with_policy_violation_count() .annotate(package_count=Count("productpackages", distinct=True)) .order_by( F("max_risk_score").desc(nulls_last=True), @@ -230,12 +231,13 @@ def with_compliance_data(self): ) def with_compliance_issues(self): - """Filter to products that have license or critical/high vulnerability issues.""" + """Filter to products that have license, vulnerability, or policy violation issues.""" return self.filter( Q(license_error_count__gt=0) | Q(license_warning_count__gt=0) | Q(critical_count__gt=0) | Q(high_count__gt=0) + | Q(policy_violation_count__gt=0) ) def with_has_vulnerable_packages(self): diff --git a/product_portfolio/views.py b/product_portfolio/views.py index a79e7a68..5682c919 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -3106,7 +3106,6 @@ def get_queryset(self): get_viewable_products(self.request.user) .with_compliance_data() .with_has_vulnerable_packages() - .with_policy_violation_count() ) def get_context_data(self, **kwargs): From 85be904107b8d125c9ee0c9faefa4cf1c2a5da1a Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 19:02:29 +0400 Subject: [PATCH 50/54] set meta.fields instead of exclude Signed-off-by: tdruez --- dje/admin.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/dje/admin.py b/dje/admin.py index 4cdce6c1..5a4762b8 100644 --- a/dje/admin.py +++ b/dje/admin.py @@ -1051,7 +1051,22 @@ class DataspaceConfigurationForm(forms.ModelForm): class Meta: model = DataspaceConfiguration - exclude = ["policy_rules_config"] + fields = [ + "homepage_layout", + "scancodeio_url", + "scancodeio_api_key", + "vulnerablecode_url", + "vulnerablecode_api_key", + "vulnerabilities_risk_threshold", + "purldb_url", + "purldb_api_key", + "forgejo_token", + "github_token", + "gitlab_token", + "jira_user", + "jira_token", + "sourcehut_token", + ] hidden_value_fields = [ "scancodeio_api_key", From f4709ef06e98235f7095158e9a61420f6026ddf1 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Jul 2026 19:04:29 +0400 Subject: [PATCH 51/54] refinements Signed-off-by: tdruez --- product_portfolio/filters.py | 1 + product_portfolio/models.py | 4 +++- product_portfolio/views.py | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 359f422f..1252f4f8 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -201,6 +201,7 @@ def filter_policy_violations(self, queryset, name, value): has_violation = ProductPolicyViolation.objects.filter( product_id=OuterRef("pk"), resolved=False, + rule_type__in=RULE_REGISTRY, ) condition = Exists(has_violation) return queryset.filter(condition if value else ~condition) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index f4596e1f..a04898bf 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -259,7 +259,9 @@ def with_policy_violation_count(self): .values("violation_count") ) return self.annotate( - policy_violation_count=Subquery(subquery, output_field=models.IntegerField()), + policy_violation_count=Coalesce( + Subquery(subquery, output_field=models.IntegerField()), Value(0) + ), ) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 5682c919..d5b4dcf1 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -113,7 +113,6 @@ from license_library.models import License from license_library.models import LicenseAssignedTag from policy.engine import evaluate_rules -from policy.engine import get_effective_config from policy.rules import RULE_REGISTRY from product_portfolio.filters import CodebaseResourceFilterSet from product_portfolio.filters import DependencyFilterSet @@ -2870,18 +2869,24 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): policy_violations = list( - product.policy_violations.filter( - resolved=False, rule_type__in=RULE_REGISTRY - ).order_by("rule_type") + product.policy_violations.filter(resolved=False, rule_type__in=RULE_REGISTRY).order_by( + "rule_type" + ) ) violated_rule_types = {violation.rule_type for violation in policy_violations} + + try: + rules_config = product.dataspace.configuration.policy_rules_config or {} + except AttributeError: + rules_config = {} + all_rules = [ { "label": handler.label, "description": handler.description, "rule_type": rule_type, "severity": handler.severity, - "is_active": get_effective_config(rule_type, product.dataspace)["is_active"], + "is_active": rules_config.get(rule_type, {}).get("is_active", False), "is_violated": rule_type in violated_rule_types, } for rule_type, handler in RULE_REGISTRY.items() @@ -3112,13 +3117,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) products = self.object_list - products_with_issues = products.filter( - Q(license_error_count__gt=0) - | Q(license_warning_count__gt=0) - | Q(critical_count__gt=0) - | Q(high_count__gt=0) - | Q(policy_violation_count__gt=0) - ).count() + products_with_issues = products.with_compliance_issues().count() products_with_license_issues = products.filter( Q(license_error_count__gt=0) | Q(license_warning_count__gt=0) From b8017ae0b1af89afe1f9dac8f364359a8d930c01 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 21 Jul 2026 10:03:55 +0400 Subject: [PATCH 52/54] add unit tests Signed-off-by: tdruez --- dje/tests/test_admin.py | 78 ++++++++++++ policy/tests/test_engine.py | 69 +++++++++++ policy/tests/test_signals.py | 113 +++++++++++++++++ product_portfolio/models.py | 2 +- product_portfolio/tests/test_admin.py | 31 ++++- .../tests/test_admin_guardian.py | 6 +- product_portfolio/tests/test_filters.py | 83 +++++++++++++ product_portfolio/tests/test_models.py | 73 +++++++++++ product_portfolio/tests/test_views.py | 116 ++++++++++++++++++ product_portfolio/views.py | 7 ++ 10 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 policy/tests/test_signals.py diff --git a/dje/tests/test_admin.py b/dje/tests/test_admin.py index 8b4c2ca4..ceb3aa1e 100644 --- a/dje/tests/test_admin.py +++ b/dje/tests/test_admin.py @@ -13,10 +13,12 @@ from django.test import override_settings from django.urls import reverse +from dje.admin import PolicyRulesConfigurationForm from dje.copier import copy_object from dje.filters import DataspaceFilter from dje.filters import MissingInFilter from dje.models import Dataspace +from dje.models import DataspaceConfiguration from dje.models import History from dje.search import advanced_search from dje.tests import add_perm @@ -25,6 +27,7 @@ from dje.tests import create_superuser from dje.tests import create_user from organization.models import Owner +from policy.rules import RULE_REGISTRY class DataspacedModelAdminTestCase(TestCase): @@ -613,3 +616,78 @@ def test_admin_group_permission_export_csv(self): 'attachment; filename="dejacode_group_permission.csv"', response["Content-Disposition"] ) self.assertEqual(b",change_license\r\nchange license,X\r\n", response.content) + + +class PolicyRulesConfigurationFormTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.config = DataspaceConfiguration.objects.create(dataspace=self.dataspace) + self.Form = PolicyRulesConfigurationForm + + def _bound_form(self, extra_data=None): + data = {} + for rule_type in RULE_REGISTRY: + data[f"rule_{rule_type}_enabled"] = False + data[f"rule_{rule_type}_threshold"] = "" + if extra_data: + data.update(extra_data) + return self.Form(data=data, instance=self.config) + + def test_enabled_rule_included_in_config(self): + form = self._bound_form({"rule_usage_policy_error_enabled": True}) + self.assertTrue(form.is_valid(), form.errors) + result = form.build_policy_rules_config() + self.assertIn("usage_policy_error", result) + self.assertTrue(result["usage_policy_error"]["is_active"]) + + def test_disabled_rule_not_in_config(self): + form = self._bound_form() + self.assertTrue(form.is_valid(), form.errors) + result = form.build_policy_rules_config() + self.assertEqual({}, result) + + def test_threshold_saved_when_set(self): + form = self._bound_form( + { + "rule_usage_policy_error_enabled": True, + "rule_usage_policy_error_threshold": "3", + } + ) + self.assertTrue(form.is_valid(), form.errors) + result = form.build_policy_rules_config() + self.assertEqual(3, result["usage_policy_error"]["threshold"]) + + def test_initial_values_loaded_from_existing_config(self): + self.config.policy_rules_config = { + "usage_policy_error": {"is_active": True, "threshold": 7} + } + self.config.save() + form = self.Form(instance=self.config) + self.assertTrue(form.fields["rule_usage_policy_error_enabled"].initial) + self.assertEqual(7, form.fields["rule_usage_policy_error_threshold"].initial) + self.assertFalse(form.fields["rule_license_coverage_gap_enabled"].initial) + + def test_save_persists_policy_rules_config_to_db(self): + form = self._bound_form({"rule_usage_policy_error_enabled": True}) + self.assertTrue(form.is_valid(), form.errors) + form.save() + self.config.refresh_from_db() + self.assertTrue(self.config.policy_rules_config["usage_policy_error"]["is_active"]) + + def test_inline_shows_rule_fieldsets_on_existing_dataspace(self): + self.super_user = create_superuser("super_user", self.dataspace) + self.client.login(username="super_user", password="secret") + url = reverse("admin:dje_dataspace_change", args=[self.dataspace.pk]) + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertContains(response, "Policy Rules Configuration") + for rule_type in RULE_REGISTRY: + self.assertContains(response, f"rule_{rule_type}_enabled") + + def test_inline_not_shown_on_dataspace_add(self): + self.super_user = create_superuser("super_user", self.dataspace) + self.client.login(username="super_user", password="secret") + url = reverse("admin:dje_dataspace_add") + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertNotContains(response, "rule_usage_policy_error_enabled") diff --git a/policy/tests/test_engine.py b/policy/tests/test_engine.py index 6ba85330..6fe11b35 100644 --- a/policy/tests/test_engine.py +++ b/policy/tests/test_engine.py @@ -11,7 +11,10 @@ from django.test import TestCase from dje.models import Dataspace +from dje.models import DataspaceConfiguration from policy.engine import evaluate_rule +from policy.engine import evaluate_rules +from policy.engine import get_effective_config from product_portfolio.models import ProductPolicyViolation from product_portfolio.tests import make_product @@ -71,3 +74,69 @@ def test_evaluate_rule_updates_count_on_existing_active_violation(self): self.assertFalse(created) self.assertEqual(7, violation.violation_count) self.assertEqual(1, ProductPolicyViolation.objects.count()) + + +class GetEffectiveConfigTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_returns_defaults_when_no_dataspace_configuration(self): + config = get_effective_config("usage_policy_error", self.dataspace) + self.assertFalse(config["is_active"]) + self.assertEqual(0, config["threshold"]) + self.assertEqual({}, config["parameters"]) + + def test_reads_dataspace_override(self): + DataspaceConfiguration.objects.create( + dataspace=self.dataspace, + policy_rules_config={"usage_policy_error": {"is_active": True, "threshold": 5}}, + ) + self.dataspace.refresh_from_db() + config = get_effective_config("usage_policy_error", self.dataspace) + self.assertTrue(config["is_active"]) + self.assertEqual(5, config["threshold"]) + + def test_falls_back_to_code_defaults_for_partial_config(self): + DataspaceConfiguration.objects.create( + dataspace=self.dataspace, + policy_rules_config={"usage_policy_error": {"is_active": True}}, + ) + self.dataspace.refresh_from_db() + config = get_effective_config("usage_policy_error", self.dataspace) + self.assertTrue(config["is_active"]) + self.assertEqual(0, config["threshold"]) + + +class EvaluateRulesTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + DataspaceConfiguration.objects.create( + dataspace=self.dataspace, + policy_rules_config={"usage_policy_error": {"is_active": True}}, + ) + self.product = make_product(self.dataspace) + + @patch("policy.engine.fire_policy_webhooks") + @patch("policy.rules.UsagePolicyErrorRule.count_violations", return_value=3) + def test_evaluate_rules_creates_violation_for_active_rule(self, _mock_count, mock_fire): + new_violations, resolved_count = evaluate_rules(self.product) + self.assertEqual(1, len(new_violations)) + self.assertEqual(0, resolved_count) + self.assertEqual(1, ProductPolicyViolation.objects.filter(resolved=False).count()) + mock_fire.assert_called_once() + + @patch("policy.engine.fire_policy_webhooks") + def test_evaluate_rules_resolves_violation_when_rule_inactive(self, _mock_fire): + ProductPolicyViolation.objects.create( + product=self.product, + dataspace=self.dataspace, + rule_type="license_coverage_gap", + violation_count=2, + ) + new_violations, resolved_count = evaluate_rules(self.product) + self.assertEqual(0, len(new_violations)) + self.assertGreater(resolved_count, 0) + self.assertTrue( + ProductPolicyViolation.objects.get(rule_type="license_coverage_gap").resolved + ) diff --git a/policy/tests/test_signals.py b/policy/tests/test_signals.py new file mode 100644 index 00000000..a3d81620 --- /dev/null +++ b/policy/tests/test_signals.py @@ -0,0 +1,113 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from unittest.mock import patch + +from django.test import TestCase + +from component_catalog.tests import make_package +from dje.models import Dataspace +from dje.models import DataspaceConfiguration +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package + + +class ProductSaveSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + @patch("policy.signals.evaluate_product_rules_task.delay") + def test_product_save_queues_task(self, mock_delay): + product = make_product(self.dataspace) + mock_delay.assert_called_with(product_uuid=product.uuid) + + @patch("policy.signals.evaluate_product_rules_task.delay") + def test_product_update_queues_task(self, mock_delay): + product = make_product(self.dataspace) + mock_delay.reset_mock() + product.save() + mock_delay.assert_called_once_with(product_uuid=product.uuid) + + +class ProductPackageSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("policy.signals.evaluate_product_rules_task.delay") + def test_productpackage_save_queues_task(self, mock_delay): + make_product_package(self.product) + mock_delay.assert_called_with(product_uuid=self.product.uuid) + + @patch("policy.signals.evaluate_product_rules_task.delay") + def test_productpackage_delete_queues_task(self, mock_delay): + pp = make_product_package(self.product) + mock_delay.reset_mock() + pp.delete() + mock_delay.assert_called_once_with(product_uuid=self.product.uuid) + + +class PackageSaveSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_package_create_does_not_queue_task(self, mock_delay): + make_package(self.dataspace) + mock_delay.assert_not_called() + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_package_update_with_products_queues_task(self, mock_delay): + package = make_package(self.dataspace) + make_product_package(self.product, package=package) + mock_delay.reset_mock() + package.save() + mock_delay.assert_called_once() + called_uuids = mock_delay.call_args[1]["product_uuids"] + self.assertIn(self.product.uuid, called_uuids) + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_package_update_without_products_does_not_queue_task(self, mock_delay): + package = make_package(self.dataspace) + mock_delay.reset_mock() + package.save() + mock_delay.assert_not_called() + + +class DataspaceConfigurationSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.config = DataspaceConfiguration.objects.create(dataspace=self.dataspace) + self.product = make_product(self.dataspace) + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_full_save_queues_task(self, mock_delay): + self.config.save() + mock_delay.assert_called_once() + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_policy_rules_config_update_fields_queues_task(self, mock_delay): + self.config.policy_rules_config = {"usage_policy_error": {"is_active": True}} + self.config.save(update_fields=["policy_rules_config"]) + mock_delay.assert_called_once() + called_uuids = mock_delay.call_args[1]["product_uuids"] + self.assertIn(self.product.uuid, called_uuids) + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_unrelated_update_fields_does_not_queue_task(self, mock_delay): + self.config.save(update_fields=["scancodeio_url"]) + mock_delay.assert_not_called() + + @patch("policy.signals.evaluate_all_products_rules_task.delay") + def test_no_products_in_dataspace_does_not_queue_task(self, mock_delay): + empty_dataspace = Dataspace.objects.create(name="Empty") + empty_config = DataspaceConfiguration.objects.create(dataspace=empty_dataspace) + mock_delay.reset_mock() + empty_config.save(update_fields=["policy_rules_config"]) + mock_delay.assert_not_called() diff --git a/product_portfolio/models.py b/product_portfolio/models.py index a04898bf..56492403 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -432,7 +432,7 @@ class Product( # WARNING: Bypass the security system implemented in ProductSecuredManager. # This is to be used only in a few cases where the User scoping is not appropriated. # For example: `self.dataspace.product_set(manager='unsecured_objects').count()` - unsecured_objects = DataspacedManager() + unsecured_objects = DataspacedManager.from_queryset(ProductQuerySet)() class Meta(BaseProductMixin.Meta): permissions = (("view_product", "Can view product"),) diff --git a/product_portfolio/tests/test_admin.py b/product_portfolio/tests/test_admin.py index 8caeaf65..6bae7d4d 100644 --- a/product_portfolio/tests/test_admin.py +++ b/product_portfolio/tests/test_admin.py @@ -6,6 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from unittest.mock import patch + from django.core.exceptions import NON_FIELD_ERRORS from django.test import TestCase from django.urls import NoReverseMatch @@ -101,7 +103,11 @@ def test_product_admin_form_clean_license_expression_in_alternate_dataspace(self def test_product_security_admin_changelist_available_actions(self): self.client.login(username=self.user.username, password="secret") response = self.client.get(self.product_changelist_url) - expected = [("", "---------"), ("mass_update", "Mass update")] + expected = [ + ("", "---------"), + ("evaluate_policy_rules", "Evaluate policy rules"), + ("mass_update", "Mass update"), + ] self.assertEqual(expected, response.context_data["action_form"].fields["action"].choices) with self.assertRaises(NoReverseMatch): @@ -539,3 +545,26 @@ def test_product_dependencies_add_view(self): dependency = self.product1.dependencies.get() self.assertEqual(self.package1, dependency.for_package) self.assertEqual(package2, dependency.resolved_to_package) + + +class EvaluatePolicyRulesActionTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + self.product1 = Product.objects.create(name="Product1", dataspace=self.dataspace) + self.product2 = Product.objects.create(name="Product2", dataspace=self.dataspace) + + @patch("product_portfolio.admin.evaluate_product_rules_task.delay") + def test_evaluate_policy_rules_action_queues_task_for_selected_products(self, mock_delay): + self.client.login(username="nexb_user", password="secret") + url = reverse("admin:product_portfolio_product_changelist") + data = { + "action": "evaluate_policy_rules", + "_selected_action": [self.product1.pk, self.product2.pk], + } + response = self.client.post(url, data, follow=True) + self.assertEqual(200, response.status_code) + self.assertEqual(2, mock_delay.call_count) + called_uuids = {call[1]["product_uuid"] for call in mock_delay.call_args_list} + self.assertIn(self.product1.uuid, called_uuids) + self.assertIn(self.product2.uuid, called_uuids) diff --git a/product_portfolio/tests/test_admin_guardian.py b/product_portfolio/tests/test_admin_guardian.py index e23a6997..12de6067 100644 --- a/product_portfolio/tests/test_admin_guardian.py +++ b/product_portfolio/tests/test_admin_guardian.py @@ -58,7 +58,11 @@ def test_product_guardian_admin_security_attributes(self): # actions = [] # actions_to_remove = ['copy_to', 'compare_with', 'delete_selected'] - expected = [("", "---------"), ("mass_update", "Mass update")] + expected = [ + ("", "---------"), + ("evaluate_policy_rules", "Evaluate policy rules"), + ("mass_update", "Mass update"), + ] self.assertEqual(expected, response.context_data["action_form"].fields["action"].choices) # activity_log = False diff --git a/product_portfolio/tests/test_filters.py b/product_portfolio/tests/test_filters.py index ce167547..024a68e2 100644 --- a/product_portfolio/tests/test_filters.py +++ b/product_portfolio/tests/test_filters.py @@ -6,15 +6,22 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from component_catalog.models import Package from component_catalog.tests import make_package from dje.models import Dataspace from license_library.models import License from organization.models import Owner +from policy.models import UsagePolicy +from product_portfolio.filters import ProductFilterSet from product_portfolio.filters import ProductPackageFilterSet +from product_portfolio.models import Product from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPolicyViolation from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package class ProductPackageFilterSetTestCase(TestCase): @@ -81,3 +88,79 @@ def test_product_package_filterset_has_licenses(self): data = {"has_licenses": "no"} filterset = ProductPackageFilterSet(dataspace=self.dataspace, data=data) self.assertQuerySetEqual(filterset.qs, [self.pp3]) + + +class ProductFilterSetPolicyTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product_with = make_product(self.dataspace) + self.product_without = make_product(self.dataspace) + ProductPolicyViolation.objects.create( + product=self.product_with, + dataspace=self.dataspace, + rule_type="usage_policy_error", + violation_count=1, + ) + + def _make_filterset(self, data): + return ProductFilterSet( + dataspace=self.dataspace, + data=data, + queryset=Product.unsecured_objects.filter(dataspace=self.dataspace), + ) + + def test_filter_policy_violations_true_returns_products_with_violations(self): + filterset = self._make_filterset({"policy_violations": "true"}) + self.assertIn(self.product_with, filterset.qs) + self.assertNotIn(self.product_without, filterset.qs) + + def test_filter_policy_violations_false_returns_products_without_violations(self): + filterset = self._make_filterset({"policy_violations": "false"}) + self.assertIn(self.product_without, filterset.qs) + self.assertNotIn(self.product_with, filterset.qs) + + def test_filter_policy_violations_excludes_stale_rule_types(self): + product_stale = make_product(self.dataspace) + ProductPolicyViolation.objects.create( + product=product_stale, + dataspace=self.dataspace, + rule_type="removed_rule", + violation_count=1, + ) + filterset = self._make_filterset({"policy_violations": "true"}) + self.assertNotIn(product_stale, filterset.qs) + + +class ProductPackageFilterByRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.owner = Owner.objects.create(name="Owner", dataspace=self.dataspace) + self.usage_policy = UsagePolicy.objects.create( + label="Error Policy", + icon="icon", + content_type=ContentType.objects.get_for_model(Package), + compliance_alert="error", + dataspace=self.dataspace, + ) + self.product = make_product(self.dataspace) + self.pp_with_policy = make_product_package( + self.product, + make_package(self.dataspace, usage_policy=self.usage_policy), + ) + self.pp_without_policy = make_product_package(self.product, make_package(self.dataspace)) + + def test_filter_by_policy_rule_filters_matching_packages(self): + filterset = ProductPackageFilterSet( + dataspace=self.dataspace, + data={"policy_rule": "usage_policy_error"}, + ) + self.assertIn(self.pp_with_policy, filterset.qs) + self.assertNotIn(self.pp_without_policy, filterset.qs) + + def test_filter_by_unknown_rule_returns_all(self): + filterset = ProductPackageFilterSet( + dataspace=self.dataspace, + data={"policy_rule": "nonexistent_rule"}, + ) + self.assertIn(self.pp_with_policy, filterset.qs) + self.assertIn(self.pp_without_policy, filterset.qs) diff --git a/product_portfolio/tests/test_models.py b/product_portfolio/tests/test_models.py index f5abe9d2..9158613f 100644 --- a/product_portfolio/tests/test_models.py +++ b/product_portfolio/tests/test_models.py @@ -35,6 +35,7 @@ from product_portfolio.models import ProductComponentAssignedLicense from product_portfolio.models import ProductInventoryItem from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPolicyViolation from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductSecuredManager from product_portfolio.models import ScanCodeProject @@ -1327,3 +1328,75 @@ def test_product_dependency_prackage_queryset_declared_dependencies_count(self): qs = Package.objects.declared_dependencies_count(product=self.product1) annotated_package1 = qs.filter(pk=package1.pk)[0] self.assertEqual(1, annotated_package1.declared_dependencies_count) + + +class ProductPolicyViolationTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def _make_violation(self, rule_type="usage_policy_error", **kwargs): + return ProductPolicyViolation.objects.create( + product=self.product, + dataspace=self.dataspace, + rule_type=rule_type, + violation_count=kwargs.pop("violation_count", 1), + **kwargs, + ) + + def test_rule_label_returns_handler_label_for_known_rule(self): + violation = self._make_violation("usage_policy_error") + self.assertEqual("Usage Policy Error", violation.rule_label) + + def test_rule_severity_returns_error_for_error_rule(self): + violation = self._make_violation("usage_policy_error") + self.assertEqual("error", violation.rule_severity) + + def test_rule_severity_returns_warning_for_unknown_rule(self): + violation = self._make_violation("nonexistent_rule") + self.assertEqual("warning", violation.rule_severity) + + def test_rule_label_falls_back_to_rule_type_for_unknown_rule(self): + violation = self._make_violation("nonexistent_rule") + self.assertEqual("nonexistent_rule", violation.rule_label) + + def test_rule_description_empty_for_unknown_rule(self): + violation = self._make_violation("nonexistent_rule") + self.assertEqual("", violation.rule_description) + + def test_with_policy_violation_count_returns_zero_when_no_violations(self): + qs = Product.unsecured_objects.filter(pk=self.product.pk).with_policy_violation_count() + self.assertEqual(0, qs.get().policy_violation_count) + + def test_with_policy_violation_count_counts_unresolved_violations(self): + self._make_violation("usage_policy_error") + self._make_violation("license_coverage_gap") + self._make_violation("usage_policy_warning", resolved=True) + qs = Product.unsecured_objects.filter(pk=self.product.pk).with_policy_violation_count() + self.assertEqual(2, qs.get().policy_violation_count) + + def test_with_compliance_issues_includes_product_with_policy_violation(self): + self._make_violation("usage_policy_error") + qs = ( + Product.unsecured_objects.filter(pk=self.product.pk) + .with_compliance_data() + .with_compliance_issues() + ) + self.assertIn(self.product, qs) + + def test_with_compliance_issues_excludes_product_with_no_issues(self): + qs = ( + Product.unsecured_objects.filter(pk=self.product.pk) + .with_compliance_data() + .with_compliance_issues() + ) + self.assertNotIn(self.product, qs) + + def test_exclude_locked_removes_locked_products(self): + locked_status = make_product_status(self.dataspace, is_locked=True) + locked_product = make_product(self.dataspace, configuration_status=locked_status) + qs = Product.unsecured_objects.filter( + pk__in=[self.product.pk, locked_product.pk] + ).exclude_locked() + self.assertIn(self.product, qs) + self.assertNotIn(locked_product, qs) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index fa4b20ad..8effb74a 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -9,6 +9,7 @@ import io import json from unittest import mock +from unittest.mock import patch from urllib.parse import quote from django.contrib.contenttypes.models import ContentType @@ -31,6 +32,7 @@ from component_catalog.tests import make_package from dejacode_toolkit import scancodeio from dje.models import Dataspace +from dje.models import DataspaceConfiguration from dje.models import History from dje.outputs import get_spdx_extracted_licenses from dje.tests import MaxQueryMixin @@ -50,6 +52,7 @@ from product_portfolio.models import ProductComponent from product_portfolio.models import ProductItemPurpose from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPolicyViolation from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductStatus from product_portfolio.models import ScanCodeProject @@ -4241,3 +4244,116 @@ def test_product_portfolio_product_security_compliance_export_view_respects_perm data = json.loads(response.content) advisory_ids = [entry["advisory_id"] for entry in data] self.assertIn(vulnerability.advisory_id, advisory_ids) + + +class EvaluatePolicyRulesViewTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + self.basic_user = create_user("basic_user", self.dataspace) + self.product1 = Product.objects.create( + name="Product1", version="1.0", dataspace=self.dataspace + ) + + def test_get_returns_405(self): + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_evaluate_policy_rules_url() + response = self.client.get(url) + self.assertEqual(405, response.status_code) + + def test_post_without_login_redirects(self): + url = self.product1.get_evaluate_policy_rules_url() + response = self.client.post(url) + self.assertEqual(302, response.status_code) + + def test_post_without_change_perm_returns_404(self): + self.client.login(username="basic_user", password="secret") + url = self.product1.get_evaluate_policy_rules_url() + response = self.client.post(url) + self.assertEqual(404, response.status_code) + + @patch("product_portfolio.views.evaluate_rules") + def test_post_with_change_perm_evaluates_and_returns_hx_refresh(self, mock_evaluate): + mock_evaluate.return_value = ([], 0) + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_evaluate_policy_rules_url() + response = self.client.post(url) + self.assertEqual(200, response.status_code) + self.assertEqual("true", response.headers["HX-Refresh"]) + mock_evaluate.assert_called_once_with(self.product1) + + +class TabCompliancePolicyContextTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + self.product1 = Product.objects.create( + name="Product1", version="1.0", dataspace=self.dataspace + ) + + def test_no_violations_context_is_empty(self): + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertEqual([], response.context["policy_violations"]) + self.assertEqual(0, response.context["policy_violation_count"]) + + def test_active_violation_appears_in_context(self): + ProductPolicyViolation.objects.create( + product=self.product1, + dataspace=self.dataspace, + rule_type="usage_policy_error", + violation_count=3, + ) + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + self.assertEqual(1, response.context["policy_violation_count"]) + self.assertEqual(1, len(response.context["policy_violations"])) + + def test_stale_rule_type_filtered_from_context(self): + ProductPolicyViolation.objects.create( + product=self.product1, + dataspace=self.dataspace, + rule_type="removed_rule", + violation_count=1, + ) + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + self.assertEqual(0, response.context["policy_violation_count"]) + + def test_all_rules_context_reflects_active_status(self): + DataspaceConfiguration.objects.create( + dataspace=self.dataspace, + policy_rules_config={"usage_policy_error": {"is_active": True}}, + ) + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + all_rules = {rule["rule_type"]: rule for rule in response.context["all_rules"]} + self.assertTrue(all_rules["usage_policy_error"]["is_active"]) + self.assertFalse(all_rules["license_coverage_gap"]["is_active"]) + + +class ComplianceDashboardPolicyViolationsTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + self.product1 = Product.objects.create( + name="Product1", version="1.0", dataspace=self.dataspace + ) + + def test_products_with_policy_violations_count(self): + ProductPolicyViolation.objects.create( + product=self.product1, + dataspace=self.dataspace, + rule_type="usage_policy_error", + violation_count=2, + ) + self.client.login(username="nexb_user", password="secret") + url = reverse("product_portfolio:compliance_dashboard") + response = self.client.get(url) + self.assertEqual(1, response.context["products_with_policy_violations"]) + self.assertEqual(1, response.context["products_with_issues"]) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index d5b4dcf1..6b73a2b9 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -3097,6 +3097,7 @@ class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, Dataspa "package_count": "Packages", "license_error_count": "License errors", "license_warning_count": "License warnings", + "max_risk_level": "Max risk level", "risk_threshold": "Risk threshold", "critical_count": "Critical", "high_count": "High", @@ -3110,6 +3111,7 @@ def get_queryset(self): return ( get_viewable_products(self.request.user) .with_compliance_data() + .with_max_risk_level() .with_has_vulnerable_packages() ) @@ -3125,6 +3127,10 @@ def get_context_data(self, **kwargs): products_with_policy_violations = products.filter(policy_violation_count__gt=0).count() + products_with_critical_or_high = products.filter( + Q(critical_count__gt=0) | Q(high_count__gt=0) + ).count() + totals = products.aggregate( total_vulnerabilities=Sum("vulnerability_count"), total_critical=Sum("critical_count"), @@ -3139,6 +3145,7 @@ def get_context_data(self, **kwargs): "products_with_issues": products_with_issues, "products_with_license_issues": products_with_license_issues, "products_with_policy_violations": products_with_policy_violations, + "products_with_critical_or_high": products_with_critical_or_high, "total_vulnerabilities": totals["total_vulnerabilities"] or 0, "total_critical": totals["total_critical"] or 0, "total_high": totals["total_high"] or 0, From 25425e1cbcbf87085f6788b8d004f41bf57488e3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 21 Jul 2026 11:55:00 +0400 Subject: [PATCH 53/54] refinements regarding violations resolution Signed-off-by: tdruez --- policy/engine.py | 26 +++++++++++++++++--------- policy/tasks.py | 8 ++++++-- product_portfolio/admin.py | 11 ++++++----- product_portfolio/filters.py | 5 ++--- product_portfolio/tests/test_admin.py | 6 +++--- product_portfolio/views.py | 6 +++--- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/policy/engine.py b/policy/engine.py index 0db4329d..e1b9c450 100644 --- a/policy/engine.py +++ b/policy/engine.py @@ -78,10 +78,14 @@ def evaluate_rule(rule_type, product, threshold, parameters): return violation, created, 0 now = timezone.now() - resolved_count = ProductPolicyViolation.objects.filter(**lookup, resolved=False).update( - resolved=True, - resolved_date=now, - last_checked=now, + resolved_count = ( + ProductPolicyViolation.objects.filter(**lookup) + .unresolved() + .update( + resolved=True, + resolved_date=now, + last_checked=now, + ) ) return None, False, resolved_count @@ -96,17 +100,21 @@ def evaluate_rules(product): """ new_violations = [] resolved_count = 0 + now = timezone.now() for rule_type in RULE_REGISTRY: config = get_effective_config(rule_type, product.dataspace) if not config["is_active"]: # Explicitly resolve open violations so disabling a rule clears its history # rather than leaving stale unresolved records. - rows = ProductPolicyViolation.objects.filter( - rule_type=rule_type, - product=product, - resolved=False, - ).update(resolved=True, resolved_date=timezone.now()) + rows = ( + ProductPolicyViolation.objects.filter( + rule_type=rule_type, + product=product, + ) + .unresolved() + .update(resolved=True, resolved_date=now, last_checked=now) + ) resolved_count += rows continue diff --git a/policy/tasks.py b/policy/tasks.py index bcedb5e0..77f8114d 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -24,7 +24,11 @@ def evaluate_product_rules_task(product_uuid): Product = apps.get_model("product_portfolio", "product") try: - product = get_unsecured_manager(Product).get(uuid=product_uuid) + product = ( + get_unsecured_manager(Product) + .select_related("dataspace__configuration") + .get(uuid=product_uuid) + ) except Product.DoesNotExist: logger.error(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.") return @@ -49,7 +53,7 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): if product_uuids is not None: products = products.filter(uuid__in=product_uuids) if not include_locked: - products = products.exclude(configuration_status__is_locked=True) + products = products.exclude_locked() count = products.count() logger.info(f"Starting policy rule evaluation for {count} product(s).") diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py index 9adbc4d5..36ece772 100644 --- a/product_portfolio/admin.py +++ b/product_portfolio/admin.py @@ -46,7 +46,7 @@ from dje.permissions import assign_all_object_permissions from dje.permissions import get_limited_perms_for_model from dje.utils import is_purl_fragment -from policy.tasks import evaluate_product_rules_task +from policy.tasks import evaluate_all_products_rules_task from product_portfolio.filters import ComponentCompletenessListFilter from product_portfolio.forms import ProductAdminForm from product_portfolio.forms import ProductComponentAdminForm @@ -397,10 +397,11 @@ class ProductAdmin( readonly_fields = DataspacedAdmin.readonly_fields + ("get_feature_datalist",) def evaluate_policy_rules(self, request, queryset): - count = queryset.count() - for product in queryset: - evaluate_product_rules_task.delay(product_uuid=product.uuid) - self.message_user(request, f"Policy rules evaluation enqueued for {count} product(s).") + product_uuids = list(queryset.values_list("uuid", flat=True)) + evaluate_all_products_rules_task.delay(product_uuids=product_uuids) + self.message_user( + request, f"Policy rules evaluation enqueued for {len(product_uuids)} product(s)." + ) evaluate_policy_rules.short_description = _("Evaluate policy rules") diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 1252f4f8..f6521aae 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -200,9 +200,8 @@ def filter_policy_violations(self, queryset, name, value): return queryset has_violation = ProductPolicyViolation.objects.filter( product_id=OuterRef("pk"), - resolved=False, - rule_type__in=RULE_REGISTRY, - ) + rule_type__in=RULE_REGISTRY.keys(), + ).unresolved() condition = Exists(has_violation) return queryset.filter(condition if value else ~condition) diff --git a/product_portfolio/tests/test_admin.py b/product_portfolio/tests/test_admin.py index 6bae7d4d..32836d5c 100644 --- a/product_portfolio/tests/test_admin.py +++ b/product_portfolio/tests/test_admin.py @@ -554,7 +554,7 @@ def setUp(self): self.product1 = Product.objects.create(name="Product1", dataspace=self.dataspace) self.product2 = Product.objects.create(name="Product2", dataspace=self.dataspace) - @patch("product_portfolio.admin.evaluate_product_rules_task.delay") + @patch("product_portfolio.admin.evaluate_all_products_rules_task.delay") def test_evaluate_policy_rules_action_queues_task_for_selected_products(self, mock_delay): self.client.login(username="nexb_user", password="secret") url = reverse("admin:product_portfolio_product_changelist") @@ -564,7 +564,7 @@ def test_evaluate_policy_rules_action_queues_task_for_selected_products(self, mo } response = self.client.post(url, data, follow=True) self.assertEqual(200, response.status_code) - self.assertEqual(2, mock_delay.call_count) - called_uuids = {call[1]["product_uuid"] for call in mock_delay.call_args_list} + mock_delay.assert_called_once() + called_uuids = set(mock_delay.call_args[1]["product_uuids"]) self.assertIn(self.product1.uuid, called_uuids) self.assertIn(self.product2.uuid, called_uuids) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 6b73a2b9..a06cc416 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2869,9 +2869,9 @@ def get_license_compliance_context(licenses, distribution_limit=10): @staticmethod def get_policy_compliance_context(product): policy_violations = list( - product.policy_violations.filter(resolved=False, rule_type__in=RULE_REGISTRY).order_by( - "rule_type" - ) + product.policy_violations.filter(rule_type__in=RULE_REGISTRY.keys()) + .unresolved() + .order_by("rule_type") ) violated_rule_types = {violation.rule_type for violation in policy_violations} From 8f42f532203f174116c68f106ad284d78ba6059f Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 21 Jul 2026 12:44:57 +0400 Subject: [PATCH 54/54] use unresolved Signed-off-by: tdruez --- product_portfolio/api.py | 2 +- product_portfolio/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/product_portfolio/api.py b/product_portfolio/api.py index af6602b4..5c74d9c0 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -437,7 +437,7 @@ def imports(self, request, uuid): def policy_violations(self, request, uuid): """List active policy violations for this product, with rule details and counts.""" product = self.get_object() - violations = product.policy_violations.filter(resolved=False) + violations = product.policy_violations.unresolved() serializer = ProductPolicyViolationSerializer(violations, many=True) return Response(serializer.data) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 56492403..70ddb03d 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -252,8 +252,8 @@ def with_policy_violation_count(self): subquery = ( ProductPolicyViolation.objects.filter( product=OuterRef("pk"), - resolved=False, ) + .unresolved() .values("product") .annotate(violation_count=models.Count("id")) .values("violation_count")