diff --git a/dje/admin.py b/dje/admin.py
index 6aa131b5..02d1c9f3 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
@@ -1142,12 +1209,13 @@ 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
+ 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."""
@@ -1160,6 +1228,40 @@ 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,
@@ -1239,7 +1341,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
new file mode 100644
index 00000000..340b8226
--- /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..996195ce 100644
--- a/dje/models.py
+++ b/dje/models.py
@@ -614,6 +614,12 @@ 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/notification/admin.py b/notification/admin.py
index d683bec2..a9d74656 100644
--- a/notification/admin.py
+++ b/notification/admin.py
@@ -64,3 +64,9 @@ class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin):
actions_to_remove = ["copy_to", "compare_with"]
email_notification_on = ()
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/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
new file mode 100644
index 00000000..142b005b
--- /dev/null
+++ b/policy/engine.py
@@ -0,0 +1,94 @@
+#
+# 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 policy.rules import RULE_REGISTRY
+from product_portfolio.models import ProductPolicyViolation
+
+
+def get_effective_config(rule_type, dataspace):
+ """
+ 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.
+ """
+ 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", {}),
+ }
+
+
+def evaluate_rule(rule_type, product, threshold, parameters):
+ """
+ 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)
+
+ lookup = {"rule_type": rule_type, "product": product, "resolved": False}
+
+ if violation_count > 0:
+ violation, created = ProductPolicyViolation.objects.get_or_create(
+ **lookup,
+ defaults={"dataspace": product.dataspace, "violation_count": violation_count},
+ )
+ if not created:
+ violation.violation_count = violation_count
+ violation.save()
+ return violation, created, 0
+
+ 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 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.
+ """
+ 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.
+ rows = ProductPolicyViolation.objects.filter(
+ rule_type=rule_type,
+ product=product,
+ resolved=False,
+ ).update(resolved=True, resolved_date=timezone.now())
+ resolved_count += rows
+ continue
+
+ violation, created, resolved = evaluate_rule(
+ rule_type, product, config["threshold"], config["parameters"]
+ )
+ if created:
+ new_violations.append(violation)
+ resolved_count += resolved
+
+ return new_violations, resolved_count
diff --git a/policy/models.py b/policy/models.py
index cc731855..ed649d0d 100755
--- a/policy/models.py
+++ b/policy/models.py
@@ -244,3 +244,32 @@ def save(self, *args, **kwargs):
if self.from_policy.content_type == self.to_policy.content_type:
raise AssertionError
super().save(*args, **kwargs)
+
+
+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
diff --git a/policy/rules.py b/policy/rules.py
new file mode 100644
index 00000000..824d97ea
--- /dev/null
+++ b/policy/rules.py
@@ -0,0 +1,130 @@
+#
+# 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
+ default_threshold = 0
+ parameters_schema = {}
+
+ 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."""
+
+ package_filter = {}
+
+ 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()
+
+ 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 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 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'."
+ )
+ 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"
+ 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 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(),
+}
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
new file mode 100644
index 00000000..237b3796
--- /dev/null
+++ b/policy/tasks.py
@@ -0,0 +1,82 @@
+#
+# 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.apps import apps
+
+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."""
+ Product = apps.get_model("product_portfolio", "product")
+
+ try:
+ 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}")
+ 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):
+ """Evaluate policy rules for every product directly, skipping locked ones by default."""
+ Product = apps.get_model("product_portfolio", "product")
+
+ products = get_unsecured_manager(Product).select_related("dataspace")
+ if not include_locked:
+ products = products.exclude_locked()
+
+ count = products.count()
+ logger.info(f"Starting policy rule evaluation for {count} product(s).")
+
+ for product in products:
+ 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).")
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/filters.py b/product_portfolio/filters.py
index 98f6f369..359f422f 100644
--- a/product_portfolio/filters.py
+++ b/product_portfolio/filters.py
@@ -31,11 +31,13 @@
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
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 +151,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 +195,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
@@ -392,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
@@ -407,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/migrations/0018_productpolicyviolation.py b/product_portfolio/migrations/0018_productpolicyviolation.py
new file mode 100644
index 00000000..9b38d420
--- /dev/null
+++ b/product_portfolio/migrations/0018_productpolicyviolation.py
@@ -0,0 +1,37 @@
+# Generated by Django 6.0.6 on 2026-07-15 08:25
+
+import django.db.models.deletion
+import dje.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dje', '0016_dataspaceconfiguration_policy_rules_config'),
+ ('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)),
+ ('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')),
+ ('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'), ('rule_type', 'product')},
+ },
+ bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
+ ),
+ ]
diff --git a/product_portfolio/models.py b/product_portfolio/models.py
index 9fb8e6c9..4b01ddd7 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
@@ -56,6 +57,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
@@ -139,6 +141,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(
@@ -240,6 +245,20 @@ 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):
"""
@@ -286,7 +305,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
@@ -422,6 +441,10 @@ 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")
@@ -1901,3 +1924,46 @@ 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."),
+ )
+ 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"), ("rule_type", "product"))
+ ordering = ["-detected_date"]
+
+ def __str__(self):
+ 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_dashboard.html b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html
index bd258e85..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 %}
+
+
+
@@ -128,8 +134,8 @@
| {% trans "Product" %} |
{% trans "Packages" %} |
{% trans "License compliance" %} |
- {% trans "Security compliance" %} |
{% trans "Vulnerabilities" %} |
+ {% trans "Policy violations" %} |
@@ -161,19 +167,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 %}
@@ -196,6 +189,15 @@
{% 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 %}
diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html
index 92c0fe29..ce9632ff 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 %}
+
+ |
+
+ {% 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 %}
+
+
{# View all link #}
{% if vulnerabilities|length < vulnerability_count %}
@@ -214,4 +224,44 @@ {% trans "Security compliance" %}
{% endif %}
-
\ No newline at end of file
+
+
+{% if policy_violations %}
+
+
+
+
+
{% trans "Policy violations" %}
+
+ {{ policy_violation_count }} {% trans "active" %}
+
+
+
+
+
+ | {% trans "Rule" %} |
+ {% trans "Description" %} |
+ {% trans "Objects in violation" %} |
+ {% trans "Detected" %} |
+
+
+
+ {% for violation in policy_violations %}
+
+ | {{ violation.rule_label }} |
+ {{ violation.rule_description }} |
+
+
+ {{ violation.violation_count }}
+
+ |
+ {{ violation.detected_date|date:"N j, Y" }} |
+
+ {% endfor %}
+
+
+
+
+
+{% 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..171c0657 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,20 @@
{% endif %}
+
+
+
{% trans "Policy violations" %}
+ {% if policy_violation_count == 0 %}
+
0
+
{% trans "No active violations" %}
+ {% else %}
+
{{ policy_violation_count }}
+
+ {% endif %}
+
+
\ No newline at end of file
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index f8e50d5f..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
@@ -2772,6 +2775,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 +2846,14 @@ def get_license_compliance_context(licenses, distribution_limit=10):
"remaining_license_count": max(0, len(license_distribution) - distribution_limit),
}
+ @staticmethod
+ def get_policy_compliance_context(product):
+ policy_violations = product.policy_violations.filter(resolved=False)
+ 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()
@@ -3042,36 +3054,40 @@ 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()
)
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)
- ).count()
+ products_with_policy_violations = products.filter(policy_violation_count__gt=0).count()
totals = products.aggregate(
total_vulnerabilities=Sum("vulnerability_count"),
@@ -3086,7 +3102,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,