Skip to content

Commit 5658579

Browse files
committed
move the rule configuration to the dataspace
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 81b2dcb commit 5658579

13 files changed

Lines changed: 106 additions & 192 deletions

File tree

dje/admin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,11 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
11411141
),
11421142
]
11431143
# Do not include the Dataspace related FKs on addition as the Dataspace does not exist yet
1144-
fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets
1144+
policy_rules_fieldset = (
1145+
"Policy Rules",
1146+
{"fields": ("policy_rules_config",)},
1147+
)
1148+
fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets + [policy_rules_fieldset]
11451149
can_delete = False
11461150

11471151
def get_fieldsets(self, request, obj=None):
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,14 @@ class DataspaceConfiguration(DataspaceForeignKeyValidationMixin, models.Model):
614614
),
615615
)
616616

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

policy/admin.py

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,9 @@
2323
from dje.admin import dejacode_site
2424
from dje.list_display import AsColored
2525
from policy.forms import AssociatedPolicyForm
26-
from policy.forms import PolicyRuleForm
2726
from policy.forms import UsagePolicyForm
2827
from policy.models import AssociatedPolicy
29-
from policy.models import PolicyRule
3028
from policy.models import UsagePolicy
31-
from policy.rules import RULE_REGISTRY
3229

3330
License = apps.get_model("license_library", "license")
3431

@@ -162,37 +159,3 @@ def download_license_dump_view(self, request):
162159
response["Content-Disposition"] = 'attachment; filename="license_policies.yml"'
163160

164161
return response
165-
166-
167-
@admin.register(PolicyRule, site=dejacode_site)
168-
class PolicyRuleAdmin(DataspacedAdmin):
169-
form = PolicyRuleForm
170-
list_display = ("name", "rule_type", "threshold", "is_active", "get_dataspace")
171-
list_filter = DataspacedAdmin.list_filter + ("rule_type", "is_active")
172-
readonly_fields = DataspacedAdmin.readonly_fields + ("parameters_schema_hint",)
173-
activity_log = False
174-
actions = []
175-
actions_to_remove = ["copy_to", "compare_with"]
176-
email_notification_on = ()
177-
178-
short_description = (
179-
"You can define Policy Rules that automatically detect compliance violations "
180-
"across your products and trigger notifications."
181-
)
182-
183-
long_description = linebreaksbr(
184-
"A Policy Rule defines a type of automated check to run against your products. "
185-
"When the number of detected issues exceeds the configured threshold, a "
186-
"ProductPolicyViolation is recorded.\n"
187-
"Set the rule type to match a registered evaluation handler and configure the "
188-
"threshold (0 means any violation triggers the rule). "
189-
)
190-
191-
def parameters_schema_hint(self, obj):
192-
handler = RULE_REGISTRY.get(obj.rule_type)
193-
if not handler or not handler.parameters_schema:
194-
return "No parameters supported for this rule type."
195-
lines = [f"{key}: {desc}" for key, desc in handler.parameters_schema.items()]
196-
return mark_safe("<br>".join(lines))
197-
198-
parameters_schema_hint.short_description = "Supported parameters"

policy/engine.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,47 @@
88

99
from django.utils import timezone
1010

11-
from policy.models import PolicyRule
1211
from policy.rules import RULE_REGISTRY
1312
from product_portfolio.models import ProductPolicyViolation
1413

1514

16-
def evaluate_rule(policy_rule, product):
15+
def get_effective_config(rule_type, dataspace):
1716
"""
18-
Evaluate a single PolicyRule against a product, create or update the
19-
ProductPolicyViolation record.
20-
Returns the ProductPolicyViolation instance, or None if no violation exists.
17+
Resolve threshold, parameters, and is_active for a rule type in a given dataspace.
18+
19+
Reads the dataspace-level override from DataspaceConfiguration.policy_rules_config,
20+
falling back to the code defaults defined on the rule handler.
2121
"""
22-
rule_handler = RULE_REGISTRY.get(policy_rule.rule_type)
23-
if not rule_handler:
24-
return
22+
handler = RULE_REGISTRY[rule_type]
23+
try:
24+
rule_config = dataspace.configuration.policy_rules_config.get(rule_type, {})
25+
except AttributeError:
26+
rule_config = {}
27+
28+
return {
29+
"is_active": rule_config.get("is_active", True),
30+
"threshold": rule_config.get("threshold", handler.default_threshold),
31+
"parameters": rule_config.get("parameters", {}),
32+
}
33+
2534

26-
violation_count = rule_handler.count_violations(policy_rule, product)
35+
def evaluate_rule(rule_type, product, threshold, parameters):
36+
"""Evaluate a single rule against a product and record the violation if triggered."""
37+
handler = RULE_REGISTRY[rule_type]
38+
violation_count = handler.count_violations(product, threshold, parameters)
2739

28-
lookup = {"policy_rule": policy_rule, "product": product, "resolved": False}
40+
lookup = {"rule_type": rule_type, "product": product, "resolved": False}
2941

3042
if violation_count > 0:
3143
violation, created = ProductPolicyViolation.objects.get_or_create(
3244
**lookup,
33-
defaults={"dataspace": policy_rule.dataspace, "violation_count": violation_count},
45+
defaults={"dataspace": product.dataspace, "violation_count": violation_count},
3446
)
3547
if not created:
3648
violation.violation_count = violation_count
3749
violation.save()
3850
return violation
51+
3952
else:
4053
ProductPolicyViolation.objects.filter(**lookup).update(
4154
resolved=True,
@@ -46,12 +59,17 @@ def evaluate_rule(policy_rule, product):
4659

4760
def evaluate_rules(product):
4861
"""
49-
Evaluate all active PolicyRules for the given product.
62+
Evaluate all rules in RULE_REGISTRY for the given product.
63+
5064
Returns the list of active ProductPolicyViolation instances.
5165
"""
5266
violations = []
53-
for policy_rule in PolicyRule.objects.scope(product.dataspace).active():
54-
violation = evaluate_rule(policy_rule, product)
67+
for rule_type in RULE_REGISTRY:
68+
config = get_effective_config(rule_type, product.dataspace)
69+
if not config["is_active"]:
70+
continue
71+
72+
violation = evaluate_rule(rule_type, product, config["threshold"], config["parameters"])
5573
if violation:
5674
violations.append(violation)
5775

policy/forms.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111

1212
from dje.forms import ColorCodeFormMixin
1313
from dje.forms import DataspacedAdminForm
14-
from policy.models import PolicyRule
15-
from policy.rules import RULE_REGISTRY
1614

1715

1816
class UsagePolicyForm(ColorCodeFormMixin, DataspacedAdminForm):
@@ -94,21 +92,3 @@ def get_ct(app_label, model):
9492
self.add_error("to_policy", msg)
9593

9694
return cleaned_data
97-
98-
99-
class PolicyRuleForm(DataspacedAdminForm):
100-
class Meta:
101-
model = PolicyRule
102-
fields = "__all__"
103-
104-
def __init__(self, *args, **kwargs):
105-
super().__init__(*args, **kwargs)
106-
self.fields["rule_type"].widget = forms.Select(
107-
choices=[(key, handler.label) for key, handler in RULE_REGISTRY.items()]
108-
)
109-
110-
def clean_rule_type(self):
111-
value = self.cleaned_data["rule_type"]
112-
if value not in RULE_REGISTRY:
113-
raise forms.ValidationError(f"Unknown rule type: {value}")
114-
return value

policy/migrations/0003_policyrule.py

Lines changed: 0 additions & 35 deletions
This file was deleted.

policy/models.py

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from dje.models import DataspacedModel
2121
from dje.models import DataspacedQuerySet
2222
from dje.models import colored_icon_mixin_factory
23-
from policy.rules import RULE_REGISTRY
2423

2524
ColoredIconMixin = colored_icon_mixin_factory(
2625
verbose_name="usage policy",
@@ -247,57 +246,6 @@ def save(self, *args, **kwargs):
247246
super().save(*args, **kwargs)
248247

249248

250-
class PolicyRuleQuerySet(DataspacedQuerySet):
251-
def active(self):
252-
return self.filter(is_active=True)
253-
254-
255-
class PolicyRule(DataspacedModel):
256-
name = models.CharField(
257-
max_length=100,
258-
help_text=_("Descriptive name for this policy rule."),
259-
)
260-
rule_type = models.CharField(
261-
max_length=50,
262-
help_text=_("The type of evaluation performed by this rule."),
263-
)
264-
threshold = models.PositiveIntegerField(
265-
default=0,
266-
help_text=_("Minimum number of violations required to trigger this rule (0 means any)."),
267-
)
268-
is_active = models.BooleanField(
269-
default=True,
270-
help_text=_("Only active rules are evaluated."),
271-
)
272-
parameters = models.JSONField(
273-
blank=True,
274-
default=dict,
275-
help_text=_(
276-
"Optional rule-specific parameters as a JSON object. "
277-
"Supported keys depend on the chosen rule type."
278-
),
279-
)
280-
281-
objects = PolicyRuleQuerySet.as_manager()
282-
283-
class Meta:
284-
unique_together = (("dataspace", "uuid"), ("dataspace", "name"))
285-
ordering = ["name"]
286-
287-
def __str__(self):
288-
return self.name
289-
290-
@property
291-
def rule_label(self):
292-
handler = RULE_REGISTRY.get(self.rule_type)
293-
return handler.label if handler else self.rule_type
294-
295-
@property
296-
def rule_description(self):
297-
handler = RULE_REGISTRY.get(self.rule_type)
298-
return handler.description if handler else ""
299-
300-
301249
class AbstractPolicyViolation(models.Model):
302250
"""Shared fields for all concrete policy violation models. No DB table."""
303251

policy/rules.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class BaseRule:
1717
description = None
1818
parameters_schema = {}
1919

20-
def count_violations(self, policy_rule, product):
20+
def count_violations(self, product, threshold, parameters):
2121
"""Count objects violating the rule for the given product."""
2222
raise NotImplementedError
2323

@@ -27,15 +27,15 @@ class PackageBaseRule(BaseRule):
2727

2828
package_filter = {}
2929

30-
def count_violations(self, policy_rule, product):
30+
def count_violations(self, product, threshold, parameters):
3131
Package = apps.get_model("component_catalog", "package")
3232

3333
count = Package.objects.filter(
3434
productpackages__product=product,
3535
**self.package_filter,
3636
).count()
3737

38-
return count if count > policy_rule.threshold else 0
38+
return count if count > threshold else 0
3939

4040

4141
class LicensePolicyErrorRule(PackageBaseRule):
@@ -73,20 +73,20 @@ class VulnerabilityDetectedRule(BaseRule):
7373
"min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.",
7474
}
7575

76-
def count_violations(self, policy_rule, product):
76+
def count_violations(self, product, threshold, parameters):
7777
Package = apps.get_model("component_catalog", "package")
7878

7979
packages = Package.objects.filter(
8080
productpackages__product=product,
8181
risk_score__isnull=False,
8282
)
8383

84-
min_risk_score = policy_rule.parameters.get("min_risk_score")
84+
min_risk_score = parameters.get("min_risk_score")
8585
if min_risk_score is not None:
8686
packages = packages.filter(risk_score__gte=min_risk_score)
8787

8888
count = packages.count()
89-
return count if count > policy_rule.threshold else 0
89+
return count if count > threshold else 0
9090

9191

9292
RULE_REGISTRY = {

product_portfolio/migrations/0018_productpolicyviolation.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 6.0.6 on 2026-07-08 08:39
1+
# Generated by Django 6.0.6 on 2026-07-15 08:25
22

33
import django.db.models.deletion
44
import dje.models
@@ -9,8 +9,7 @@
99
class Migration(migrations.Migration):
1010

1111
dependencies = [
12-
('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'),
13-
('policy', '0003_policyrule'),
12+
('dje', '0016_dataspaceconfiguration_policy_rules_config'),
1413
('product_portfolio', '0017_scancodeproject_import_options'),
1514
]
1615

@@ -25,13 +24,13 @@ class Migration(migrations.Migration):
2524
('last_checked', models.DateTimeField(auto_now=True, help_text='The date and time of the last evaluation.')),
2625
('resolved', models.BooleanField(default=False, help_text='Indicates whether this violation has been resolved.')),
2726
('resolved_date', models.DateTimeField(blank=True, help_text='The date and time when this violation was resolved.', null=True)),
27+
('rule_type', models.CharField(help_text='The rule type from the rule registry that triggered this violation.', max_length=50)),
2828
('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')),
29-
('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')),
3029
('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')),
3130
],
3231
options={
3332
'ordering': ['-detected_date'],
34-
'unique_together': {('dataspace', 'uuid'), ('policy_rule', 'product')},
33+
'unique_together': {('dataspace', 'uuid'), ('rule_type', 'product')},
3534
},
3635
bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
3736
),

0 commit comments

Comments
 (0)