Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0b3315c
add PolicyRule and PolicyViolation models
tdruez Jul 7, 2026
8a03e34
add ProductPolicyViolation model
tdruez Jul 7, 2026
4e6f9d8
add base class for rules
tdruez Jul 7, 2026
c384036
add admin views for managing rules
tdruez Jul 7, 2026
d468a54
add rules evaluation engine
tdruez Jul 7, 2026
7302e19
add tasks for rules evaluation
tdruez Jul 7, 2026
b32b73c
exclude locked products
tdruez Jul 7, 2026
76b0a21
add parameters system to the rule model
tdruez Jul 7, 2026
f55f1ff
implement multiple rules
tdruez Jul 8, 2026
8055350
fire event
tdruez Jul 8, 2026
403838b
decouple notification from the rules
tdruez Jul 8, 2026
bce3f73
display policy violation in views
tdruez Jul 8, 2026
ce7ffe4
add link from compliance view
tdruez Jul 8, 2026
1dee081
refine the dashboard view
tdruez Jul 8, 2026
81b2dcb
add link to policy table
tdruez Jul 14, 2026
5658579
move the rule configuration to the dataspace
tdruez Jul 15, 2026
8fdad32
add policy rules configuration inadmin
tdruez Jul 15, 2026
12ea0de
resolve open violations
tdruez Jul 15, 2026
a576b6d
add signals to trigger rule evaluation
tdruez Jul 15, 2026
406cd16
fix rendering
tdruez Jul 15, 2026
19a1c96
fix bug in view
tdruez Jul 15, 2026
34597f7
use porper manager
tdruez Jul 15, 2026
f8287c2
do not display delivery history on addition
tdruez Jul 15, 2026
b482699
implement fire_policy_webhooks
tdruez Jul 15, 2026
ee5e986
add filter by policy rule
tdruez Jul 15, 2026
976ffec
fix names for usage policy rules
tdruez Jul 15, 2026
0790847
add license policy rules
tdruez Jul 15, 2026
d3aafbe
Merge branch 'main' into 408-5c-rules-engine
tdruez Jul 16, 2026
ec0137d
add a hourly cron to evaluate all rules
tdruez Jul 17, 2026
ec00077
rework the compliance tab UI
tdruez Jul 17, 2026
158df92
adjust compliance dashboard
tdruez Jul 17, 2026
584ee9d
refine polivy violation table
tdruez Jul 17, 2026
5065b58
replace popover with modal
tdruez Jul 17, 2026
4ca34f5
add ability to re-evaluate rules
tdruez Jul 17, 2026
cb94f97
plug signals to evaluate rules
tdruez Jul 17, 2026
1b6e412
refine evaluate_product_rules_on_package_save
tdruez Jul 17, 2026
4285ca7
fix tasks bug and add unit tests
tdruez Jul 17, 2026
4e1762c
add REST API action for product policy violations
tdruez Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dejacode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,9 @@ def gettext_noop(s):

# Cron jobs (scheduler)
daily_at_3am = "0 3 * * *"
hourly = "0 * * * *"
DEJACODE_VULNERABILITIES_CRON = env.str("DEJACODE_VULNERABILITIES_CRON", default=daily_at_3am)
DEJACODE_POLICY_RULES_CRON = env.str("DEJACODE_POLICY_RULES_CRON", default=hourly)


def enable_rq_eager_mode():
Expand Down
116 changes: 109 additions & 7 deletions dje/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions dje/cron_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from rq import cron

from dje.tasks import update_vulnerabilities
from policy.tasks import evaluate_all_products_rules_task

two_hour = 7200

Expand All @@ -20,3 +21,10 @@
cron=settings.DEJACODE_VULNERABILITIES_CRON, # Daily at 3am by default
job_timeout=two_hour,
)

cron.register(
func=evaluate_all_products_rules_task,
queue_name="default",
cron=settings.DEJACODE_POLICY_RULES_CRON, # Hourly by default
job_timeout=two_hour,
)
18 changes: 18 additions & 0 deletions dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py
Original file line number Diff line number Diff line change
@@ -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.'),
),
]
6 changes: 6 additions & 0 deletions dje/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
6 changes: 6 additions & 0 deletions notification/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions notification/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"user.added_or_updated",
"user.locked_out",
"vulnerability.data_update",
"policy.violation_detected",
"policy.violation_resolved",
]


Expand Down
3 changes: 2 additions & 1 deletion notification/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from django_rq import job

from dje.models import get_unsecured_manager
from notification.models import WebhookSubscription

logger = logging.getLogger("dje")
Expand All @@ -36,7 +37,7 @@ def deliver_webhook_task(
if instance_app_label and instance_model_name and instance_pk:
try:
model_class = apps.get_model(instance_app_label, instance_model_name)
instance = model_class.objects.get(pk=instance_pk)
instance = get_unsecured_manager(model_class).get(pk=instance_pk)
except Exception:
logger.error(
f"Instance {instance_app_label}.{instance_model_name} pk={instance_pk} not found."
Expand Down
42 changes: 42 additions & 0 deletions notification/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#

import json
import uuid
from unittest.mock import patch

from django.conf import settings
Expand All @@ -16,13 +17,54 @@
from dje.models import Dataspace
from dje.tests import create_superuser
from notification.models import WebhookSubscription
from notification.tasks import deliver_webhook_task
from product_portfolio.tests import make_product
from workflow.models import Priority
from workflow.models import Question
from workflow.models import Request
from workflow.models import RequestComment
from workflow.models import RequestTemplate


class DeliverWebhookTaskTestCase(TestCase):
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
self.webhook = WebhookSubscription.objects.create(
dataspace=self.dataspace,
target_url="http://127.0.0.1:8000/",
event="policy.violation_detected",
)

@patch("notification.models.WebhookSubscription.deliver")
def test_deliver_webhook_task_resolves_secured_model_instance(self, mock_deliver):
product = make_product(self.dataspace)
deliver_webhook_task(
webhook_subscription_uuid=self.webhook.uuid,
payload_override={"text": "test"},
instance_app_label="product_portfolio",
instance_model_name="product",
instance_pk=product.pk,
)
mock_deliver.assert_called_once()
delivered_instance = mock_deliver.call_args[0][0]
self.assertEqual(product, delivered_instance)

def test_deliver_webhook_task_missing_subscription_logs_error(self):
with self.assertLogs("dje", level="ERROR") as captured:
deliver_webhook_task(webhook_subscription_uuid=uuid.uuid4())
self.assertTrue(any("not found" in line for line in captured.output))

def test_deliver_webhook_task_missing_instance_logs_error(self):
with self.assertLogs("dje", level="ERROR") as captured:
deliver_webhook_task(
webhook_subscription_uuid=self.webhook.uuid,
instance_app_label="product_portfolio",
instance_model_name="product",
instance_pk=99999999,
)
self.assertTrue(any("not found" in line for line in captured.output))


class NotificationTasksTestCase(TestCase):
def setUp(self):
self.nexb_dataspace = Dataspace.objects.create(name="nexB")
Expand Down
3 changes: 3 additions & 0 deletions policy/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@
class PolicyConfig(AppConfig):
name = "policy"
verbose_name = _("Policy")

def ready(self):
import policy.signals # noqa: F401
Loading
Loading