Skip to content

Commit fb41ff4

Browse files
authored
feat: [five-c] Create rules-based engine #409 (#552)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent d0d2a14 commit fb41ff4

36 files changed

Lines changed: 1880 additions & 87 deletions

dejacode/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,9 @@ def gettext_noop(s):
475475

476476
# Cron jobs (scheduler)
477477
daily_at_3am = "0 3 * * *"
478+
hourly = "0 * * * *"
478479
DEJACODE_VULNERABILITIES_CRON = env.str("DEJACODE_VULNERABILITIES_CRON", default=daily_at_3am)
480+
DEJACODE_POLICY_RULES_CRON = env.str("DEJACODE_POLICY_RULES_CRON", default=hourly)
479481

480482

481483
def enable_rq_eager_mode():

dje/admin.py

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
from dje.views import manage_tab_permissions_view
9797
from dje.views import object_compare_view
9898
from dje.views import object_copy_view
99+
from policy.rules import RULE_REGISTRY
99100

100101
EXTERNAL_SOURCE_LOOKUP = "external_references__external_source_id"
101102

@@ -1046,12 +1047,26 @@ def render(self, name, value, attrs=None, renderer=None):
10461047

10471048

10481049
class DataspaceConfigurationForm(forms.ModelForm):
1049-
"""
1050-
Configure Dataspace settings.
1050+
"""Configure Dataspace integration settings, with sensitive values hidden in the UI."""
10511051

1052-
This form includes fields for various API keys, with sensitive values
1053-
hidden in the UI using the HiddenValueWidget.
1054-
"""
1052+
class Meta:
1053+
model = DataspaceConfiguration
1054+
fields = [
1055+
"homepage_layout",
1056+
"scancodeio_url",
1057+
"scancodeio_api_key",
1058+
"vulnerablecode_url",
1059+
"vulnerablecode_api_key",
1060+
"vulnerabilities_risk_threshold",
1061+
"purldb_url",
1062+
"purldb_api_key",
1063+
"forgejo_token",
1064+
"github_token",
1065+
"gitlab_token",
1066+
"jira_user",
1067+
"jira_token",
1068+
"sourcehut_token",
1069+
]
10551070

10561071
hidden_value_fields = [
10571072
"scancodeio_api_key",
@@ -1078,6 +1093,73 @@ def clean(self):
10781093
del self.cleaned_data[field_name]
10791094

10801095

1096+
class PolicyRulesConfigurationForm(forms.ModelForm):
1097+
"""Form for configuring policy rule overrides stored in policy_rules_config."""
1098+
1099+
class Meta:
1100+
model = DataspaceConfiguration
1101+
fields = []
1102+
1103+
def __init__(self, *args, **kwargs):
1104+
super().__init__(*args, **kwargs)
1105+
self.add_policy_rule_config_fields()
1106+
1107+
def add_policy_rule_config_fields(self):
1108+
"""Inject per-rule form fields with initial values from the saved policy_rules_config."""
1109+
config = getattr(self.instance, "policy_rules_config", {}) or {}
1110+
for rule_type, handler in RULE_REGISTRY.items():
1111+
rule_config = config.get(rule_type, {})
1112+
self.fields[f"rule_{rule_type}_enabled"] = forms.BooleanField(
1113+
label="Enable this rule",
1114+
required=False,
1115+
initial=rule_config.get("is_active", False),
1116+
)
1117+
self.fields[f"rule_{rule_type}_threshold"] = forms.IntegerField(
1118+
label="Threshold",
1119+
required=False,
1120+
min_value=0,
1121+
initial=rule_config.get("threshold"),
1122+
widget=forms.NumberInput(
1123+
attrs={"placeholder": f"Default: {handler.default_threshold}"}
1124+
),
1125+
help_text="Minimum violations to trigger the rule. Leave blank to use the default.",
1126+
)
1127+
for param_name, param_desc in handler.parameters_schema.items():
1128+
self.fields[f"rule_{rule_type}_param_{param_name}"] = forms.FloatField(
1129+
label=param_name.replace("_", " ").title(),
1130+
required=False,
1131+
initial=(rule_config.get("parameters") or {}).get(param_name),
1132+
help_text=param_desc,
1133+
)
1134+
1135+
def build_policy_rules_config(self):
1136+
"""Serialize the per-rule form fields back into the policy_rules_config dict."""
1137+
policy_rules_config = {}
1138+
for rule_type, handler in RULE_REGISTRY.items():
1139+
rule_config = {}
1140+
if self.cleaned_data.get(f"rule_{rule_type}_enabled"):
1141+
rule_config["is_active"] = True
1142+
threshold = self.cleaned_data.get(f"rule_{rule_type}_threshold")
1143+
if threshold is not None:
1144+
rule_config["threshold"] = threshold
1145+
parameters = {}
1146+
for param_name in handler.parameters_schema:
1147+
param_value = self.cleaned_data.get(f"rule_{rule_type}_param_{param_name}")
1148+
if param_value is not None:
1149+
parameters[param_name] = param_value
1150+
if parameters:
1151+
rule_config["parameters"] = parameters
1152+
if rule_config:
1153+
policy_rules_config[rule_type] = rule_config
1154+
return policy_rules_config
1155+
1156+
def save(self, commit=True):
1157+
self.instance.policy_rules_config = self.build_policy_rules_config()
1158+
if commit:
1159+
self.instance.save(update_fields=["policy_rules_config"])
1160+
return self.instance
1161+
1162+
10811163
class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
10821164
model = DataspaceConfiguration
10831165
form = DataspaceConfigurationForm
@@ -1142,12 +1224,13 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
11421224
]
11431225
# Do not include the Dataspace related FKs on addition as the Dataspace does not exist yet
11441226
fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets
1227+
inline_classes = ("grp-collapse grp-open",)
11451228
can_delete = False
11461229

11471230
def get_fieldsets(self, request, obj=None):
11481231
if not obj:
11491232
return self.add_fieldsets
1150-
return super().get_fieldsets(request, obj)
1233+
return [("", {"fields": ("homepage_layout",)})] + self.add_fieldsets
11511234

11521235
def get_readonly_fields(self, request, obj=None):
11531236
"""Only a user from the current Dataspace can edit Dataspace related FKs."""
@@ -1160,6 +1243,40 @@ def get_readonly_fields(self, request, obj=None):
11601243
return readonly_fields
11611244

11621245

1246+
class PolicyRulesConfigurationInline(DataspacedFKMixin, admin.StackedInline):
1247+
model = DataspaceConfiguration
1248+
form = PolicyRulesConfigurationForm
1249+
verbose_name_plural = _("Policy Rules Configuration")
1250+
verbose_name = _("Policy Rules Configuration")
1251+
classes = ("grp-collapse grp-open",)
1252+
inline_classes = ("grp-collapse grp-open",)
1253+
can_delete = False
1254+
1255+
def get_fieldsets(self, request, obj=None):
1256+
if not obj:
1257+
return []
1258+
rule_fieldsets = []
1259+
for rule_type, handler in RULE_REGISTRY.items():
1260+
fields = [f"rule_{rule_type}_enabled", f"rule_{rule_type}_threshold"]
1261+
for param_name in handler.parameters_schema:
1262+
fields.append(f"rule_{rule_type}_param_{param_name}")
1263+
rule_fieldsets.append(
1264+
(
1265+
handler.label,
1266+
{
1267+
"fields": fields,
1268+
"description": handler.description,
1269+
"classes": ("grp-collapse grp-open",),
1270+
},
1271+
)
1272+
)
1273+
return rule_fieldsets
1274+
1275+
def get_formset(self, request, obj=None, **kwargs):
1276+
kwargs["fields"] = []
1277+
return super().get_formset(request, obj, **kwargs)
1278+
1279+
11631280
@admin.register(Dataspace, site=dejacode_site)
11641281
class DataspaceAdmin(
11651282
ReferenceOnlyPermissions,
@@ -1239,7 +1356,7 @@ class DataspaceAdmin(
12391356
),
12401357
)
12411358
search_fields = ("name",)
1242-
inlines = [DataspaceConfigurationInline]
1359+
inlines = [DataspaceConfigurationInline, PolicyRulesConfigurationInline]
12431360
form = DataspaceAdminForm
12441361
change_form_template = "admin/dje/dataspace/change_form.html"
12451362
change_list_template = "admin/change_list_extended.html"

dje/cron_jobs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from rq import cron
1212

1313
from dje.tasks import update_vulnerabilities
14+
from policy.tasks import evaluate_all_products_rules_task
1415

1516
two_hour = 7200
1617

@@ -20,3 +21,10 @@
2021
cron=settings.DEJACODE_VULNERABILITIES_CRON, # Daily at 3am by default
2122
job_timeout=two_hour,
2223
)
24+
25+
cron.register(
26+
func=evaluate_all_products_rules_task,
27+
queue_name="default",
28+
cron=settings.DEJACODE_POLICY_RULES_CRON, # Hourly by default
29+
job_timeout=two_hour,
30+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 6.0.6 on 2026-07-15 08:25
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataspaceconfiguration',
15+
name='policy_rules_config',
16+
field=models.JSONField(blank=True, default=dict, help_text='Override default policy rule settings for this dataspace.'),
17+
),
18+
]

dje/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,12 @@ class DataspaceConfiguration(DataspaceForeignKeyValidationMixin, models.Model):
614614
),
615615
)
616616

617+
policy_rules_config = models.JSONField(
618+
blank=True,
619+
default=dict,
620+
help_text=_("Override default policy rule settings for this dataspace."),
621+
)
622+
617623
def __str__(self):
618624
return f"{self.dataspace}"
619625

dje/tests/test_admin.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
from django.test import override_settings
1414
from django.urls import reverse
1515

16+
from dje.admin import PolicyRulesConfigurationForm
1617
from dje.copier import copy_object
1718
from dje.filters import DataspaceFilter
1819
from dje.filters import MissingInFilter
1920
from dje.models import Dataspace
21+
from dje.models import DataspaceConfiguration
2022
from dje.models import History
2123
from dje.search import advanced_search
2224
from dje.tests import add_perm
@@ -25,6 +27,7 @@
2527
from dje.tests import create_superuser
2628
from dje.tests import create_user
2729
from organization.models import Owner
30+
from policy.rules import RULE_REGISTRY
2831

2932

3033
class DataspacedModelAdminTestCase(TestCase):
@@ -178,6 +181,8 @@ def test_dataspace_admin_changeform_update_packages_from_scan_field_validation(s
178181
"update_packages_from_scan": True,
179182
"configuration-TOTAL_FORMS": 0,
180183
"configuration-INITIAL_FORMS": 0,
184+
"configuration-2-TOTAL_FORMS": 0,
185+
"configuration-2-INITIAL_FORMS": 0,
181186
}
182187

183188
response = self.client.post(url, data)
@@ -611,3 +616,78 @@ def test_admin_group_permission_export_csv(self):
611616
'attachment; filename="dejacode_group_permission.csv"', response["Content-Disposition"]
612617
)
613618
self.assertEqual(b",change_license\r\nchange license,X\r\n", response.content)
619+
620+
621+
class PolicyRulesConfigurationFormTestCase(TestCase):
622+
def setUp(self):
623+
self.dataspace = Dataspace.objects.create(name="nexB")
624+
self.config = DataspaceConfiguration.objects.create(dataspace=self.dataspace)
625+
self.Form = PolicyRulesConfigurationForm
626+
627+
def _bound_form(self, extra_data=None):
628+
data = {}
629+
for rule_type in RULE_REGISTRY:
630+
data[f"rule_{rule_type}_enabled"] = False
631+
data[f"rule_{rule_type}_threshold"] = ""
632+
if extra_data:
633+
data.update(extra_data)
634+
return self.Form(data=data, instance=self.config)
635+
636+
def test_enabled_rule_included_in_config(self):
637+
form = self._bound_form({"rule_usage_policy_error_enabled": True})
638+
self.assertTrue(form.is_valid(), form.errors)
639+
result = form.build_policy_rules_config()
640+
self.assertIn("usage_policy_error", result)
641+
self.assertTrue(result["usage_policy_error"]["is_active"])
642+
643+
def test_disabled_rule_not_in_config(self):
644+
form = self._bound_form()
645+
self.assertTrue(form.is_valid(), form.errors)
646+
result = form.build_policy_rules_config()
647+
self.assertEqual({}, result)
648+
649+
def test_threshold_saved_when_set(self):
650+
form = self._bound_form(
651+
{
652+
"rule_usage_policy_error_enabled": True,
653+
"rule_usage_policy_error_threshold": "3",
654+
}
655+
)
656+
self.assertTrue(form.is_valid(), form.errors)
657+
result = form.build_policy_rules_config()
658+
self.assertEqual(3, result["usage_policy_error"]["threshold"])
659+
660+
def test_initial_values_loaded_from_existing_config(self):
661+
self.config.policy_rules_config = {
662+
"usage_policy_error": {"is_active": True, "threshold": 7}
663+
}
664+
self.config.save()
665+
form = self.Form(instance=self.config)
666+
self.assertTrue(form.fields["rule_usage_policy_error_enabled"].initial)
667+
self.assertEqual(7, form.fields["rule_usage_policy_error_threshold"].initial)
668+
self.assertFalse(form.fields["rule_license_coverage_gap_enabled"].initial)
669+
670+
def test_save_persists_policy_rules_config_to_db(self):
671+
form = self._bound_form({"rule_usage_policy_error_enabled": True})
672+
self.assertTrue(form.is_valid(), form.errors)
673+
form.save()
674+
self.config.refresh_from_db()
675+
self.assertTrue(self.config.policy_rules_config["usage_policy_error"]["is_active"])
676+
677+
def test_inline_shows_rule_fieldsets_on_existing_dataspace(self):
678+
self.super_user = create_superuser("super_user", self.dataspace)
679+
self.client.login(username="super_user", password="secret")
680+
url = reverse("admin:dje_dataspace_change", args=[self.dataspace.pk])
681+
response = self.client.get(url)
682+
self.assertEqual(200, response.status_code)
683+
self.assertContains(response, "Policy Rules Configuration")
684+
for rule_type in RULE_REGISTRY:
685+
self.assertContains(response, f"rule_{rule_type}_enabled")
686+
687+
def test_inline_not_shown_on_dataspace_add(self):
688+
self.super_user = create_superuser("super_user", self.dataspace)
689+
self.client.login(username="super_user", password="secret")
690+
url = reverse("admin:dje_dataspace_add")
691+
response = self.client.get(url)
692+
self.assertEqual(200, response.status_code)
693+
self.assertNotContains(response, "rule_usage_policy_error_enabled")

dje/tests/test_history.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ def test_history_on_admin_dataspace_add(self):
5858
"name": "new_dataspace",
5959
"configuration-TOTAL_FORMS": 0,
6060
"configuration-INITIAL_FORMS": 0,
61+
"configuration-2-TOTAL_FORMS": 0,
62+
"configuration-2-INITIAL_FORMS": 0,
6163
}
6264

6365
self.client.post(url, params)

notification/admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,9 @@ class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin):
6464
actions_to_remove = ["copy_to", "compare_with"]
6565
email_notification_on = ()
6666
inlines = [WebhookDeliveryInline]
67+
68+
def get_inlines(self, request, obj=None):
69+
"""Exclude delivery history on the add form as no deliveries exist yet."""
70+
if obj is None:
71+
return []
72+
return super().get_inlines(request, obj)

notification/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"user.added_or_updated",
2929
"user.locked_out",
3030
"vulnerability.data_update",
31+
"policy.violation_detected",
32+
"policy.violation_resolved",
3133
]
3234

3335

notification/tasks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from django_rq import job
1414

15+
from dje.models import get_unsecured_manager
1516
from notification.models import WebhookSubscription
1617

1718
logger = logging.getLogger("dje")
@@ -36,7 +37,7 @@ def deliver_webhook_task(
3637
if instance_app_label and instance_model_name and instance_pk:
3738
try:
3839
model_class = apps.get_model(instance_app_label, instance_model_name)
39-
instance = model_class.objects.get(pk=instance_pk)
40+
instance = get_unsecured_manager(model_class).get(pk=instance_pk)
4041
except Exception:
4142
logger.error(
4243
f"Instance {instance_app_label}.{instance_model_name} pk={instance_pk} not found."

0 commit comments

Comments
 (0)