diff --git a/policy/admin.py b/policy/admin.py
index 645f3b9e..ba9d52a3 100755
--- a/policy/admin.py
+++ b/policy/admin.py
@@ -23,9 +23,12 @@
from dje.admin import dejacode_site
from dje.list_display import AsColored
from policy.forms import AssociatedPolicyForm
+from policy.forms import PolicyRuleForm
from policy.forms import UsagePolicyForm
from policy.models import AssociatedPolicy
+from policy.models import PolicyRule
from policy.models import UsagePolicy
+from policy.rules import RULE_REGISTRY
License = apps.get_model("license_library", "license")
@@ -159,3 +162,37 @@ def download_license_dump_view(self, request):
response["Content-Disposition"] = 'attachment; filename="license_policies.yml"'
return response
+
+
+@admin.register(PolicyRule, site=dejacode_site)
+class PolicyRuleAdmin(DataspacedAdmin):
+ form = PolicyRuleForm
+ list_display = ("name", "rule_type", "threshold", "is_active", "get_dataspace")
+ list_filter = DataspacedAdmin.list_filter + ("rule_type", "is_active")
+ readonly_fields = DataspacedAdmin.readonly_fields + ("parameters_schema_hint",)
+ activity_log = False
+ actions = []
+ actions_to_remove = ["copy_to", "compare_with"]
+ email_notification_on = ()
+
+ short_description = (
+ "You can define Policy Rules that automatically detect compliance violations "
+ "across your products and trigger notifications."
+ )
+
+ long_description = linebreaksbr(
+ "A Policy Rule defines a type of automated check to run against your products. "
+ "When the number of detected issues exceeds the configured threshold, a "
+ "ProductPolicyViolation is recorded.\n"
+ "Set the rule type to match a registered evaluation handler and configure the "
+ "threshold (0 means any violation triggers the rule). "
+ )
+
+ def parameters_schema_hint(self, obj):
+ handler = RULE_REGISTRY.get(obj.rule_type)
+ if not handler or not handler.parameters_schema:
+ return "No parameters supported for this rule type."
+ lines = [f"{key}: {desc}" for key, desc in handler.parameters_schema.items()]
+ return mark_safe("
".join(lines))
+
+ parameters_schema_hint.short_description = "Supported parameters"
diff --git a/policy/engine.py b/policy/engine.py
new file mode 100644
index 00000000..2e623848
--- /dev/null
+++ b/policy/engine.py
@@ -0,0 +1,58 @@
+#
+# 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.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.
+ Returns the ProductPolicyViolation instance, or None if no violation exists.
+ """
+ rule_handler = RULE_REGISTRY.get(policy_rule.rule_type)
+ if not rule_handler:
+ return
+
+ 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()
+ return violation
+ else:
+ ProductPolicyViolation.objects.filter(**lookup).update(
+ resolved=True,
+ resolved_date=timezone.now(),
+ )
+ return
+
+
+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(product.dataspace).active():
+ violation = evaluate_rule(policy_rule, product)
+ if violation:
+ violations.append(violation)
+
+ return violations
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
diff --git a/policy/migrations/0003_policyrule.py b/policy/migrations/0003_policyrule.py
new file mode 100644
index 00000000..41558ab5
--- /dev/null
+++ b/policy/migrations/0003_policyrule.py
@@ -0,0 +1,35 @@
+# Generated by Django 6.0.6 on 2026-07-08 08:39
+
+import django.db.models.deletion
+import dje.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'),
+ ('policy', '0002_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PolicyRule',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
+ ('name', models.CharField(help_text='Descriptive name for this policy rule.', max_length=100)),
+ ('rule_type', models.CharField(help_text='The type of evaluation performed by this rule.', max_length=50)),
+ ('threshold', models.PositiveIntegerField(default=0, help_text='Minimum number of violations required to trigger this rule (0 means any).')),
+ ('is_active', models.BooleanField(default=True, help_text='Only active rules are evaluated.')),
+ ('parameters', models.JSONField(blank=True, default=dict, help_text='Optional rule-specific parameters as a JSON object. Supported keys depend on the chosen rule type.')),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ],
+ options={
+ 'ordering': ['name'],
+ 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')},
+ },
+ bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
+ ),
+ ]
diff --git a/policy/models.py b/policy/models.py
index cc731855..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",
@@ -244,3 +245,83 @@ 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."),
+ )
+ parameters = models.JSONField(
+ blank=True,
+ default=dict,
+ help_text=_(
+ "Optional rule-specific parameters as a JSON object. "
+ "Supported keys depend on the chosen rule type."
+ ),
+ )
+
+ objects = PolicyRuleQuerySet.as_manager()
+
+ class Meta:
+ unique_together = (("dataspace", "uuid"), ("dataspace", "name"))
+ ordering = ["name"]
+
+ def __str__(self):
+ return self.name
+
+ @property
+ def rule_label(self):
+ handler = RULE_REGISTRY.get(self.rule_type)
+ return handler.label if handler else self.rule_type
+
+ @property
+ def rule_description(self):
+ handler = RULE_REGISTRY.get(self.rule_type)
+ return handler.description if handler else ""
+
+
+class AbstractPolicyViolation(models.Model):
+ """Shared fields for all concrete policy violation models. No DB table."""
+
+ 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..1dd0e4d3
--- /dev/null
+++ b/policy/rules.py
@@ -0,0 +1,97 @@
+#
+# 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
+ parameters_schema = {}
+
+ def count_violations(self, policy_rule, product):
+ """Count objects violating the rule for the given product."""
+ raise NotImplementedError
+
+
+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")
+
+ packages = Package.objects.filter(
+ productpackages__product=product,
+ 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 = {
+ LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(),
+ LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(),
+ LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(),
+ VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(),
+}
diff --git a/policy/tasks.py b/policy/tasks.py
new file mode 100644
index 00000000..b6523449
--- /dev/null
+++ b/policy/tasks.py
@@ -0,0 +1,39 @@
+#
+# 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."""
+ 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(include_locked=False):
+ """Enqueue evaluate_product_rules_task for every product, skipping locked ones by default."""
+ Product = apps.get_model("product_portfolio", "product")
+
+ products = Product.objects.select_related("dataspace")
+ 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/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..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/migrations/0018_productpolicyviolation.py b/product_portfolio/migrations/0018_productpolicyviolation.py
new file mode 100644
index 00000000..2558daaa
--- /dev/null
+++ b/product_portfolio/migrations/0018_productpolicyviolation.py
@@ -0,0 +1,38 @@
+# Generated by Django 6.0.6 on 2026-07-08 08:39
+
+import django.db.models.deletion
+import dje.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'),
+ ('policy', '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..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
@@ -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,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):
"""
@@ -286,7 +300,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 +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")
@@ -1901,3 +1919,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)"
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 "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" }} | +