From 0b3315cef425fd162cc96247240a0411bf9707d2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 7 Jul 2026 08:50:31 +0400 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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,