diff --git a/docs/content/asset_modelling/PRO__asset_attribute_options.md b/docs/content/asset_modelling/PRO__asset_attribute_options.md new file mode 100644 index 00000000000..7db4bda6611 --- /dev/null +++ b/docs/content/asset_modelling/PRO__asset_attribute_options.md @@ -0,0 +1,48 @@ +--- +title: "Editable Platform, Lifecycle and Origin Lists" +description: "Customize the Platform, Lifecycle and Origin dropdown options on your assets" +audience: pro +weight: 8 +--- + +The **Platform**, **Lifecycle** and **Origin** fields on an asset used to be fixed lists that only +DefectDojo could change. They are now editable lookup tables, so an administrator can rename the +built-in options and add their own to match how the organization actually describes its assets. This +works the same way the **Environments** list has always worked. + +## Managing the options + +Open **Settings > Configuration** and pick **Platforms**, **Lifecycles** or **Origins**. Each page +lists the current options with: + +- **Label** — the name shown in dropdowns and on the asset (for example, "API" or "Production"). This + is the part you edit. +- **Value** — a fixed machine key stored on the asset and used by the API, imports and automation + rules. It is set when an option is created and never changes afterward, so relabeling an option + never breaks an integration. +- **Icon** and **Display order** — optional. The icon is a Font Awesome name; display order controls + where the option appears in the dropdown. +- **Assets Using** — how many assets currently reference the option. + +Use **New Platform** (or Lifecycle/Origin) to add an option, click an option's label to edit it, and +delete an option from its edit screen. An option that is still in use by an asset cannot be deleted; +reassign or clear those assets first. + +## How your changes appear + +New and renamed options show up immediately in the **Platform / Lifecycle / Origin** dropdowns on the +asset add and edit forms, and their labels are what appears on the asset detail page, in the asset +list, in reports and on dashboard tiles. + +## What the API sees + +Nothing about the API contract changes. These fields are still sent and returned as the option's +**value** string (for example `"web service"` or `"production"`), so existing integrations, imports +and exports keep working. When you add an option, its value must exist before an asset or an import +can use it; an unknown value is rejected, exactly as before. + +## A note on Business Criticality + +**Business Criticality** is intentionally **not** editable. Its values feed asset and finding +prioritization, so its list stays fixed. If you need a bespoke attribute that should not affect +prioritization, use [Custom Fields](../pro__custom_fields/) instead. diff --git a/dojo/asset/api/filters.py b/dojo/asset/api/filters.py index 237c8e3af7f..417695e8dda 100644 --- a/dojo/asset/api/filters.py +++ b/dojo/asset/api/filters.py @@ -17,6 +17,11 @@ Product, Product_API_Scan_Configuration, ) +from dojo.product_attributes.choices import ( + lifecycle_value_choices, + origin_value_choices, + platform_value_choices, +) labels = get_labels() @@ -38,9 +43,9 @@ class ApiAssetFilter(DojoFilter): name_exact = CharFilter(field_name="name", lookup_expr="iexact") description = CharFilter(lookup_expr="icontains") business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES) - platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES) - lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES) - origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES) + platform = MultipleChoiceFilter(field_name="platform__value", choices=platform_value_choices) + lifecycle = MultipleChoiceFilter(field_name="lifecycle__value", choices=lifecycle_value_choices) + origin = MultipleChoiceFilter(field_name="origin__value", choices=origin_value_choices) # NumberInFilter id = NumberInFilter(field_name="id", lookup_expr="in") asset_manager = NumberInFilter(field_name="product_manager", lookup_expr="in") @@ -81,9 +86,9 @@ class ApiAssetFilter(DojoFilter): ("created", "created"), ("prod_numeric_grade", "asset_numeric_grade"), ("business_criticality", "business_criticality"), - ("platform", "platform"), - ("lifecycle", "lifecycle"), - ("origin", "origin"), + ("platform__name", "platform"), + ("lifecycle__name", "lifecycle"), + ("origin__name", "origin"), ("revenue", "revenue"), ("external_audience", "external_audience"), ("internet_accessible", "internet_accessible"), diff --git a/dojo/asset/api/serializers.py b/dojo/asset/api/serializers.py index 849822e79f2..79be16ccd4a 100644 --- a/dojo/asset/api/serializers.py +++ b/dojo/asset/api/serializers.py @@ -9,6 +9,9 @@ Dojo_User, Product, Product_API_Scan_Configuration, + Product_Lifecycle, + Product_Origin, + Product_Platform, ) from dojo.organization.api.serializers import RelatedOrganizationField from dojo.product.queries import get_authorized_products @@ -44,9 +47,9 @@ class AssetSerializer(AuthorizedUsersMemberGuardMixin, serializers.ModelSerializ required=False, allow_null=True, ) business_criticality = serializers.ChoiceField(choices=Product.BUSINESS_CRITICALITY_CHOICES, allow_blank=True, allow_null=True, required=False) - platform = serializers.ChoiceField(choices=Product.PLATFORM_CHOICES, allow_blank=True, allow_null=True, required=False) - lifecycle = serializers.ChoiceField(choices=Product.LIFECYCLE_CHOICES, allow_blank=True, allow_null=True, required=False) - origin = serializers.ChoiceField(choices=Product.ORIGIN_CHOICES, allow_blank=True, allow_null=True, required=False) + platform = serializers.SlugRelatedField(slug_field="value", queryset=Product_Platform.objects.all(), allow_null=True, required=False) + lifecycle = serializers.SlugRelatedField(slug_field="value", queryset=Product_Lifecycle.objects.all(), allow_null=True, required=False) + origin = serializers.SlugRelatedField(slug_field="value", queryset=Product_Origin.objects.all(), allow_null=True, required=False) class Meta: model = Product diff --git a/dojo/authorization/api_permissions.py b/dojo/authorization/api_permissions.py index b08bbf852cb..24e5fdd37f2 100644 --- a/dojo/authorization/api_permissions.py +++ b/dojo/authorization/api_permissions.py @@ -34,6 +34,10 @@ Test, ) +# Imported from the leaf module (not dojo.models) to avoid a circular import during +# dojo.models loading, matching how Location is imported above. +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform + def check_post_permission( request: Request, @@ -1115,6 +1119,38 @@ class UserHasDevelopmentEnvironmentPermission(BaseDjangoModelPermission): } +class UserHasProductPlatformPermission(BaseDjangoModelPermission): + django_model = Product_Platform + # Reads are open to any authenticated user (the asset form and asset views need to + # render the option labels). Writes require the configuration permission. + request_method_permission_map = { + "POST": "add", + "PUT": "change", + "PATCH": "change", + "DELETE": "delete", + } + + +class UserHasProductLifecyclePermission(BaseDjangoModelPermission): + django_model = Product_Lifecycle + request_method_permission_map = { + "POST": "add", + "PUT": "change", + "PATCH": "change", + "DELETE": "delete", + } + + +class UserHasProductOriginPermission(BaseDjangoModelPermission): + django_model = Product_Origin + request_method_permission_map = { + "POST": "add", + "PUT": "change", + "PATCH": "change", + "DELETE": "delete", + } + + class UserHasRegulationPermission(BaseDjangoModelPermission): django_model = Regulation # https://github.com/DefectDojo/django-DefectDojo/blob/963d4a35bfd8f5138330f0d70595a755fa4999b0/dojo/user/utils.py#L104 diff --git a/dojo/db_migrations/0297_customizable_asset_attributes.py b/dojo/db_migrations/0297_customizable_asset_attributes.py new file mode 100644 index 00000000000..47e525b03fb --- /dev/null +++ b/dojo/db_migrations/0297_customizable_asset_attributes.py @@ -0,0 +1,273 @@ +# Convert Product.platform / lifecycle / origin from CharField(choices) to ForeignKey +# to the editable dojo.product_attributes lookup tables, preserving existing data. +# +# The naive AlterField that makemigrations produces would try to cast the stored +# strings ("web service", "production", ...) straight to integer ids and lose the data. +# Instead each field is converted in place: add the new *_id column, backfill it from the +# option table by matching on ``value``, then drop the old string column. Any value not +# already seeded (a stray written directly to the DB / via import) gets an option row +# created for it first, so no asset or audit-history row loses its value. +# +# Product is pghistory-tracked, so its row triggers are dropped before the data work and +# recreated (against the new *_id columns) afterwards, and the mirrored productevent +# columns are converted the same way. +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + +# (value, label, font-awesome icon base name, display_order) seeded from the old choice tuples / +# display tags. ``value`` is the machine string the Product fields used to store and still expose. +PLATFORMS = [ + ("web service", "API", "plug", 10), + ("desktop", "Desktop", "desktop", 20), + ("iot", "Internet of Things", "shuffle", 30), + ("mobile", "Mobile", "mobile", 40), + ("web", "Web", "rectangle-list", 50), +] +LIFECYCLES = [ + ("construction", "Construction", "compass", 10), + ("production", "Production", "ship", 20), + ("retirement", "Retirement", "moon", 30), +] +ORIGINS = [ + ("third party library", "Third Party Library", "book", 10), + ("purchased", "Purchased", "money-bill", 20), + ("contractor", "Contractor Developed", "suitcase", 30), + ("internal", "Internally Developed", "home", 40), + ("open source", "Open Source", "code", 50), + ("outsourced", "Outsourced", "globe", 60), +] + + +def seed_options(apps, schema_editor): + Product_Platform = apps.get_model("dojo", "Product_Platform") + Product_Lifecycle = apps.get_model("dojo", "Product_Lifecycle") + Product_Origin = apps.get_model("dojo", "Product_Origin") + for model, rows in ( + (Product_Platform, PLATFORMS), + (Product_Lifecycle, LIFECYCLES), + (Product_Origin, ORIGINS), + ): + for value, label, icon, order in rows: + model.objects.get_or_create( + value=value, + defaults={"name": label, "icon": icon, "display_order": order}, + ) + + +def _seed_strays_sql(option_table, source_column): + return f""" + INSERT INTO {option_table} (value, name, icon, display_order) + SELECT DISTINCT s.v, INITCAP(s.v), '', 100 + FROM ( + SELECT {source_column} AS v FROM dojo_product + WHERE {source_column} IS NOT NULL AND {source_column} <> '' + UNION + SELECT {source_column} AS v FROM dojo_productevent + WHERE {source_column} IS NOT NULL AND {source_column} <> '' + ) s + WHERE NOT EXISTS (SELECT 1 FROM {option_table} o WHERE o.value = s.v); + """ + + +SEED_STRAYS = ( + _seed_strays_sql("dojo_product_platform", "platform") + + _seed_strays_sql("dojo_product_lifecycle", "lifecycle") + + _seed_strays_sql("dojo_product_origin", "origin") +) + + +def _convert_product_fk_sql(column, option_table, constraint, index): + # dojo_product carries DEFERRABLE INITIALLY DEFERRED foreign keys, so the UPDATE + # queues deferred constraint checks ("pending trigger events") that would block the + # following ALTER TABLE. SET CONSTRAINTS ALL IMMEDIATE forces them to run now and + # clears the queue (it also stops later UPDATEs in this transaction from deferring). + return f""" + ALTER TABLE dojo_product ADD COLUMN {column}_id integer NULL; + UPDATE dojo_product p SET {column}_id = o.id + FROM {option_table} o WHERE o.value = p.{column}; + SET CONSTRAINTS ALL IMMEDIATE; + ALTER TABLE dojo_product DROP COLUMN {column}; + ALTER TABLE dojo_product ADD CONSTRAINT {constraint} + FOREIGN KEY ({column}_id) REFERENCES {option_table} (id) + DEFERRABLE INITIALLY DEFERRED; + CREATE INDEX {index} ON dojo_product ({column}_id); + """ + + +def _revert_product_fk_sql(column, option_table, constraint, index): + return f""" + ALTER TABLE dojo_product ADD COLUMN {column} varchar(50) NULL; + UPDATE dojo_product p SET {column} = o.value + FROM {option_table} o WHERE o.id = p.{column}_id; + ALTER TABLE dojo_product DROP CONSTRAINT {constraint}; + DROP INDEX {index}; + ALTER TABLE dojo_product DROP COLUMN {column}_id; + """ + + +def _convert_event_fk_sql(column, option_table): + # The pghistory event table carries no FK constraint or index (db_constraint=False, + # db_index=False), so only the column type is converted. + return f""" + ALTER TABLE dojo_productevent ADD COLUMN {column}_id integer NULL; + UPDATE dojo_productevent e SET {column}_id = o.id + FROM {option_table} o WHERE o.value = e.{column}; + SET CONSTRAINTS ALL IMMEDIATE; + ALTER TABLE dojo_productevent DROP COLUMN {column}; + """ + + +def _revert_event_fk_sql(column, option_table): + return f""" + ALTER TABLE dojo_productevent ADD COLUMN {column} varchar(50) NULL; + UPDATE dojo_productevent e SET {column} = o.value + FROM {option_table} o WHERE o.id = e.{column}_id; + ALTER TABLE dojo_productevent DROP COLUMN {column}_id; + """ + + +def _product_fk(name, target): + return migrations.AlterField( + model_name="product", + name=name, + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, + related_name="products", to=target), + ) + + +def _event_fk(name, target): + return migrations.AlterField( + model_name="productevent", + name=name, + field=models.ForeignKey(blank=True, db_constraint=False, db_index=False, null=True, + on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", + related_query_name="+", to=target), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0296_drop_unused_finding_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='Product_Lifecycle', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(help_text='Stable machine value used by the API, imports and automation rules. Immutable once created.', max_length=50, unique=True)), + ('name', models.CharField(help_text='Label shown in dropdowns and on the asset.', max_length=200)), + ('icon', models.CharField(blank=True, default='', help_text='Optional Font Awesome icon class (classic UI only).', max_length=100)), + ('display_order', models.IntegerField(default=0, help_text='Optional ordering for the dropdown (lower first).')), + ], + options={ + 'ordering': ['display_order', 'name'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Product_Origin', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(help_text='Stable machine value used by the API, imports and automation rules. Immutable once created.', max_length=50, unique=True)), + ('name', models.CharField(help_text='Label shown in dropdowns and on the asset.', max_length=200)), + ('icon', models.CharField(blank=True, default='', help_text='Optional Font Awesome icon class (classic UI only).', max_length=100)), + ('display_order', models.IntegerField(default=0, help_text='Optional ordering for the dropdown (lower first).')), + ], + options={ + 'ordering': ['display_order', 'name'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Product_Platform', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(help_text='Stable machine value used by the API, imports and automation rules. Immutable once created.', max_length=50, unique=True)), + ('name', models.CharField(help_text='Label shown in dropdowns and on the asset.', max_length=200)), + ('icon', models.CharField(blank=True, default='', help_text='Optional Font Awesome icon class (classic UI only).', max_length=100)), + ('display_order', models.IntegerField(default=0, help_text='Optional ordering for the dropdown (lower first).')), + ], + options={ + 'ordering': ['display_order', 'name'], + 'abstract': False, + }, + ), + migrations.RunPython(seed_options, migrations.RunPython.noop), + + pgtrigger.migrations.RemoveTrigger(model_name='product', name='insert_insert'), + pgtrigger.migrations.RemoveTrigger(model_name='product', name='update_update'), + pgtrigger.migrations.RemoveTrigger(model_name='product', name='delete_delete'), + + # Create option rows for any values not already seeded, so the backfills below + # map every existing value. + migrations.RunSQL(SEED_STRAYS, reverse_sql=migrations.RunSQL.noop), + + # Product: string -> FK, data preserved. + migrations.SeparateDatabaseAndState( + state_operations=[_product_fk("platform", "dojo.product_platform")], + database_operations=[migrations.RunSQL( + _convert_product_fk_sql("platform", "dojo_product_platform", + "dojo_product_platform_id_fk", "dojo_product_platform_id_idx"), + reverse_sql=_revert_product_fk_sql("platform", "dojo_product_platform", + "dojo_product_platform_id_fk", "dojo_product_platform_id_idx"), + )], + ), + migrations.SeparateDatabaseAndState( + state_operations=[_product_fk("lifecycle", "dojo.product_lifecycle")], + database_operations=[migrations.RunSQL( + _convert_product_fk_sql("lifecycle", "dojo_product_lifecycle", + "dojo_product_lifecycle_id_fk", "dojo_product_lifecycle_id_idx"), + reverse_sql=_revert_product_fk_sql("lifecycle", "dojo_product_lifecycle", + "dojo_product_lifecycle_id_fk", "dojo_product_lifecycle_id_idx"), + )], + ), + migrations.SeparateDatabaseAndState( + state_operations=[_product_fk("origin", "dojo.product_origin")], + database_operations=[migrations.RunSQL( + _convert_product_fk_sql("origin", "dojo_product_origin", + "dojo_product_origin_id_fk", "dojo_product_origin_id_idx"), + reverse_sql=_revert_product_fk_sql("origin", "dojo_product_origin", + "dojo_product_origin_id_fk", "dojo_product_origin_id_idx"), + )], + ), + + # productevent (pghistory mirror): string -> FK id, historical data preserved. + migrations.SeparateDatabaseAndState( + state_operations=[_event_fk("platform", "dojo.product_platform")], + database_operations=[migrations.RunSQL( + _convert_event_fk_sql("platform", "dojo_product_platform"), + reverse_sql=_revert_event_fk_sql("platform", "dojo_product_platform"), + )], + ), + migrations.SeparateDatabaseAndState( + state_operations=[_event_fk("lifecycle", "dojo.product_lifecycle")], + database_operations=[migrations.RunSQL( + _convert_event_fk_sql("lifecycle", "dojo_product_lifecycle"), + reverse_sql=_revert_event_fk_sql("lifecycle", "dojo_product_lifecycle"), + )], + ), + migrations.SeparateDatabaseAndState( + state_operations=[_event_fk("origin", "dojo.product_origin")], + database_operations=[migrations.RunSQL( + _convert_event_fk_sql("origin", "dojo_product_origin"), + reverse_sql=_revert_event_fk_sql("origin", "dojo_product_origin"), + )], + ), + + pgtrigger.migrations.AddTrigger( + model_name='product', + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_productevent" ("async_updating", "business_criticality", "created", "description", "disable_sla_breach_notifications", "enable_full_risk_acceptance", "enable_product_tag_inheritance", "enable_simple_risk_acceptance", "external_audience", "id", "internet_accessible", "lifecycle_id", "name", "origin_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "platform_id", "prod_numeric_grade", "prod_type_id", "product_manager_id", "revenue", "sla_configuration_id", "team_manager_id", "technical_contact_id", "tid", "updated", "user_records") VALUES (NEW."async_updating", NEW."business_criticality", NEW."created", NEW."description", NEW."disable_sla_breach_notifications", NEW."enable_full_risk_acceptance", NEW."enable_product_tag_inheritance", NEW."enable_simple_risk_acceptance", NEW."external_audience", NEW."id", NEW."internet_accessible", NEW."lifecycle_id", NEW."name", NEW."origin_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."platform_id", NEW."prod_numeric_grade", NEW."prod_type_id", NEW."product_manager_id", NEW."revenue", NEW."sla_configuration_id", NEW."team_manager_id", NEW."technical_contact_id", NEW."tid", NEW."updated", NEW."user_records"); RETURN NULL;', hash='56d7d4e2ec7a3c855444408a7cd6be18e2e9ab02', operation='INSERT', pgid='pgtrigger_insert_insert_d5d32', table='dojo_product', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='product', + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."async_updating" IS DISTINCT FROM (NEW."async_updating") OR OLD."business_criticality" IS DISTINCT FROM (NEW."business_criticality") OR OLD."description" IS DISTINCT FROM (NEW."description") OR OLD."disable_sla_breach_notifications" IS DISTINCT FROM (NEW."disable_sla_breach_notifications") OR OLD."enable_full_risk_acceptance" IS DISTINCT FROM (NEW."enable_full_risk_acceptance") OR OLD."enable_product_tag_inheritance" IS DISTINCT FROM (NEW."enable_product_tag_inheritance") OR OLD."enable_simple_risk_acceptance" IS DISTINCT FROM (NEW."enable_simple_risk_acceptance") OR OLD."external_audience" IS DISTINCT FROM (NEW."external_audience") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."internet_accessible" IS DISTINCT FROM (NEW."internet_accessible") OR OLD."lifecycle_id" IS DISTINCT FROM (NEW."lifecycle_id") OR OLD."name" IS DISTINCT FROM (NEW."name") OR OLD."origin_id" IS DISTINCT FROM (NEW."origin_id") OR OLD."platform_id" IS DISTINCT FROM (NEW."platform_id") OR OLD."prod_numeric_grade" IS DISTINCT FROM (NEW."prod_numeric_grade") OR OLD."prod_type_id" IS DISTINCT FROM (NEW."prod_type_id") OR OLD."product_manager_id" IS DISTINCT FROM (NEW."product_manager_id") OR OLD."revenue" IS DISTINCT FROM (NEW."revenue") OR OLD."sla_configuration_id" IS DISTINCT FROM (NEW."sla_configuration_id") OR OLD."team_manager_id" IS DISTINCT FROM (NEW."team_manager_id") OR OLD."technical_contact_id" IS DISTINCT FROM (NEW."technical_contact_id") OR OLD."tid" IS DISTINCT FROM (NEW."tid") OR OLD."user_records" IS DISTINCT FROM (NEW."user_records"))', func='INSERT INTO "dojo_productevent" ("async_updating", "business_criticality", "created", "description", "disable_sla_breach_notifications", "enable_full_risk_acceptance", "enable_product_tag_inheritance", "enable_simple_risk_acceptance", "external_audience", "id", "internet_accessible", "lifecycle_id", "name", "origin_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "platform_id", "prod_numeric_grade", "prod_type_id", "product_manager_id", "revenue", "sla_configuration_id", "team_manager_id", "technical_contact_id", "tid", "updated", "user_records") VALUES (NEW."async_updating", NEW."business_criticality", NEW."created", NEW."description", NEW."disable_sla_breach_notifications", NEW."enable_full_risk_acceptance", NEW."enable_product_tag_inheritance", NEW."enable_simple_risk_acceptance", NEW."external_audience", NEW."id", NEW."internet_accessible", NEW."lifecycle_id", NEW."name", NEW."origin_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."platform_id", NEW."prod_numeric_grade", NEW."prod_type_id", NEW."product_manager_id", NEW."revenue", NEW."sla_configuration_id", NEW."team_manager_id", NEW."technical_contact_id", NEW."tid", NEW."updated", NEW."user_records"); RETURN NULL;', hash='49923bd314b57154a13024be447d70ff62dda123', operation='UPDATE', pgid='pgtrigger_update_update_e7040', table='dojo_product', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='product', + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_productevent" ("async_updating", "business_criticality", "created", "description", "disable_sla_breach_notifications", "enable_full_risk_acceptance", "enable_product_tag_inheritance", "enable_simple_risk_acceptance", "external_audience", "id", "internet_accessible", "lifecycle_id", "name", "origin_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "platform_id", "prod_numeric_grade", "prod_type_id", "product_manager_id", "revenue", "sla_configuration_id", "team_manager_id", "technical_contact_id", "tid", "updated", "user_records") VALUES (OLD."async_updating", OLD."business_criticality", OLD."created", OLD."description", OLD."disable_sla_breach_notifications", OLD."enable_full_risk_acceptance", OLD."enable_product_tag_inheritance", OLD."enable_simple_risk_acceptance", OLD."external_audience", OLD."id", OLD."internet_accessible", OLD."lifecycle_id", OLD."name", OLD."origin_id", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."platform_id", OLD."prod_numeric_grade", OLD."prod_type_id", OLD."product_manager_id", OLD."revenue", OLD."sla_configuration_id", OLD."team_manager_id", OLD."technical_contact_id", OLD."tid", OLD."updated", OLD."user_records"); RETURN NULL;', hash='fb5075dda22c29fcfbf16c799b636b5dbf993777', operation='DELETE', pgid='pgtrigger_delete_delete_064dd', table='dojo_product', when='AFTER')), + ), + ] diff --git a/dojo/engagement/ui/filters.py b/dojo/engagement/ui/filters.py index 055da964976..c060607f4aa 100644 --- a/dojo/engagement/ui/filters.py +++ b/dojo/engagement/ui/filters.py @@ -21,6 +21,7 @@ Test, Test_Type, ) +from dojo.product_attributes.choices import lifecycle_value_choices from dojo.product_type.queries import get_authorized_product_types from dojo.user.queries import get_authorized_users @@ -39,7 +40,8 @@ class EngagementDirectFilterHelper(FilterSet): target_start = DateRangeFilter() target_end = DateRangeFilter() test__engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, + field_name="test__engagement__product__lifecycle__value", + choices=lifecycle_value_choices, label=labels.ASSET_LIFECYCLE_LABEL, null_label="Empty") o = OrderingFilter( @@ -123,7 +125,8 @@ class EngagementFilterHelper(FilterSet): engagement__version = CharFilter(field_name="engagement__version", lookup_expr="icontains", label="Engagement version") engagement__test__version = CharFilter(field_name="engagement__test__version", lookup_expr="icontains", label="Test version") engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, + field_name="engagement__product__lifecycle__value", + choices=lifecycle_value_choices, label=labels.ASSET_LIFECYCLE_LABEL, null_label="Empty") engagement__status = MultipleChoiceFilter( diff --git a/dojo/finding/api/filters.py b/dojo/finding/api/filters.py index 7d599246f74..75034dad9e5 100644 --- a/dojo/finding/api/filters.py +++ b/dojo/finding/api/filters.py @@ -64,8 +64,8 @@ class ApiFindingFilter(DojoFilter): exact_title = CharFilter(field_name="title", lookup_expr="iexact", help_text="Finding title exact match (case-insensitive)") product_name = CharFilter(lookup_expr="engagement__product__name__iexact", field_name="test", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) product_name_contains = CharFilter(lookup_expr="engagement__product__name__icontains", field_name="test", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) - product_lifecycle = CharFilter(method=custom_filter, lookup_expr="engagement__product__lifecycle", - field_name="test__engagement__product__lifecycle", label=labels.ASSET_FILTERS_CSV_LIFECYCLES_LABEL) + product_lifecycle = CharFilter(method=custom_filter, lookup_expr="engagement__product__lifecycle__value", + field_name="test__engagement__product__lifecycle__value", label=labels.ASSET_FILTERS_CSV_LIFECYCLES_LABEL) # DateRangeFilter created = DateRangeFilter() date = DateRangeFilter() diff --git a/dojo/finding/ui/filters.py b/dojo/finding/ui/filters.py index d5dd971bd60..eb018338e19 100644 --- a/dojo/finding/ui/filters.py +++ b/dojo/finding/ui/filters.py @@ -64,6 +64,7 @@ Test_Type, ) from dojo.product.queries import get_authorized_products +from dojo.product_attributes.choices import lifecycle_value_choices from dojo.product_type.queries import get_authorized_product_types from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests @@ -107,7 +108,8 @@ class FindingFilterHelper(FilterSet): test_import_finding_action__test_import = NumberFilter(widget=HiddenInput()) status = FindingStatusFilter(label="Status") test__engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, + field_name="test__engagement__product__lifecycle__value", + choices=lifecycle_value_choices, label=labels.ASSET_LIFECYCLE_LABEL) if locations_enabled(): location_status = MultipleChoiceFilter( @@ -902,7 +904,7 @@ class ReportFindingFilter(ReportFindingFilterHelper, FindingTagFilter): test__engagement__product__prod_type = ModelMultipleChoiceFilter( queryset=Product_Type.objects.none(), label=labels.ORG_FILTERS_LABEL) - test__engagement__product__lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, label=labels.ASSET_LIFECYCLE_LABEL) + test__engagement__product__lifecycle = MultipleChoiceFilter(field_name="test__engagement__product__lifecycle__value", choices=lifecycle_value_choices, label=labels.ASSET_LIFECYCLE_LABEL) test__engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") duplicate_finding = ModelChoiceFilter(queryset=Finding.objects.filter(original_finding__isnull=False).distinct()) diff --git a/dojo/fixtures/defect_dojo_sample_data.json b/dojo/fixtures/defect_dojo_sample_data.json index b3ca698f469..081881a4780 100644 --- a/dojo/fixtures/defect_dojo_sample_data.json +++ b/dojo/fixtures/defect_dojo_sample_data.json @@ -2524,10 +2524,10 @@ "enable_simple_risk_acceptance": false, "external_audience": true, "internet_accessible": true, - "lifecycle": "production", + "lifecycle": 2, "name": "BodgeIt", - "origin": "internal", - "platform": "web", + "origin": 4, + "platform": 5, "prod_numeric_grade": 5, "prod_type": 2, "product_manager": [ @@ -2567,10 +2567,10 @@ "enable_simple_risk_acceptance": false, "external_audience": false, "internet_accessible": false, - "lifecycle": "construction", + "lifecycle": 1, "name": "Internal CRM App", - "origin": "internal", - "platform": "web", + "origin": 4, + "platform": 5, "prod_numeric_grade": 51, "prod_type": 2, "product_manager": [ @@ -2605,10 +2605,10 @@ "enable_simple_risk_acceptance": false, "external_audience": true, "internet_accessible": false, - "lifecycle": "production", + "lifecycle": 2, "name": "Apple Accounting Software", - "origin": "purchased", - "platform": "web", + "origin": 2, + "platform": 5, "prod_numeric_grade": 100, "prod_type": 3, "product_manager": [ diff --git a/dojo/fixtures/defect_dojo_sample_data_locations.json b/dojo/fixtures/defect_dojo_sample_data_locations.json index cd04cfd4c20..9741486eec5 100644 --- a/dojo/fixtures/defect_dojo_sample_data_locations.json +++ b/dojo/fixtures/defect_dojo_sample_data_locations.json @@ -2533,10 +2533,10 @@ "enable_simple_risk_acceptance": false, "external_audience": true, "internet_accessible": true, - "lifecycle": "production", + "lifecycle": 2, "name": "BodgeIt", - "origin": "internal", - "platform": "web", + "origin": 4, + "platform": 5, "prod_numeric_grade": 5, "prod_type": 2, "product_manager": [ @@ -2576,10 +2576,10 @@ "enable_simple_risk_acceptance": false, "external_audience": false, "internet_accessible": false, - "lifecycle": "construction", + "lifecycle": 1, "name": "Internal CRM App", - "origin": "internal", - "platform": "web", + "origin": 4, + "platform": 5, "prod_numeric_grade": 51, "prod_type": 2, "product_manager": [ @@ -2614,10 +2614,10 @@ "enable_simple_risk_acceptance": false, "external_audience": true, "internet_accessible": false, - "lifecycle": "production", + "lifecycle": 2, "name": "Apple Accounting Software", - "origin": "purchased", - "platform": "web", + "origin": 2, + "platform": 5, "prod_numeric_grade": 100, "prod_type": 3, "product_manager": [ @@ -77474,14 +77474,14 @@ "external_audience": true, "id": 1, "internet_accessible": true, - "lifecycle": "production", + "lifecycle": 2, "name": "BodgeIt", - "origin": "internal", + "origin": 4, "pgh_context": null, "pgh_created_at": "2026-02-26T19:36:31.394385054Z", "pgh_label": "insert", "pgh_obj": 1, - "platform": "web", + "platform": 5, "prod_numeric_grade": 5, "prod_type": 2, "product_manager": [ @@ -77515,14 +77515,14 @@ "external_audience": false, "id": 2, "internet_accessible": false, - "lifecycle": "construction", + "lifecycle": 1, "name": "Internal CRM App", - "origin": "internal", + "origin": 4, "pgh_context": null, "pgh_created_at": "2026-02-26T19:36:31.394385054Z", "pgh_label": "insert", "pgh_obj": 2, - "platform": "web", + "platform": 5, "prod_numeric_grade": 51, "prod_type": 2, "product_manager": [ @@ -77556,14 +77556,14 @@ "external_audience": true, "id": 3, "internet_accessible": false, - "lifecycle": "production", + "lifecycle": 2, "name": "Apple Accounting Software", - "origin": "purchased", + "origin": 2, "pgh_context": null, "pgh_created_at": "2026-02-26T19:36:31.394385054Z", "pgh_label": "insert", "pgh_obj": 3, - "platform": "web", + "platform": 5, "prod_numeric_grade": 100, "prod_type": 3, "product_manager": [ diff --git a/dojo/models.py b/dojo/models.py index 0f2abfeea58..040f118ddba 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -383,6 +383,11 @@ def __str__(self): Engagement, Engagement_Presets, # noqa: F401 -- re-export ) +from dojo.product_attributes.models import ( # noqa: E402, F401 -- re-export; Product FKs below reference these + Product_Lifecycle, + Product_Origin, + Product_Platform, +) class Sonarqube_Issue(models.Model): diff --git a/dojo/product/api/filters.py b/dojo/product/api/filters.py index e75b95684ad..9c80898186a 100644 --- a/dojo/product/api/filters.py +++ b/dojo/product/api/filters.py @@ -18,6 +18,11 @@ ) from dojo.labels import get_labels from dojo.models import Product +from dojo.product_attributes.choices import ( + lifecycle_value_choices, + origin_value_choices, + platform_value_choices, +) labels = get_labels() @@ -31,9 +36,9 @@ class ApiProductFilter(DojoFilter): name_exact = CharFilter(field_name="name", lookup_expr="iexact") description = CharFilter(lookup_expr="icontains") business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES) - platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES) - lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES) - origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES) + platform = MultipleChoiceFilter(field_name="platform__value", choices=platform_value_choices) + lifecycle = MultipleChoiceFilter(field_name="lifecycle__value", choices=lifecycle_value_choices) + origin = MultipleChoiceFilter(field_name="origin__value", choices=origin_value_choices) # NumberInFilter id = NumberInFilter(field_name="id", lookup_expr="in") product_manager = NumberInFilter(field_name="product_manager", lookup_expr="in") @@ -74,9 +79,9 @@ class ApiProductFilter(DojoFilter): ("created", "created"), ("prod_numeric_grade", "prod_numeric_grade"), ("business_criticality", "business_criticality"), - ("platform", "platform"), - ("lifecycle", "lifecycle"), - ("origin", "origin"), + ("platform__name", "platform"), + ("lifecycle__name", "lifecycle"), + ("origin__name", "origin"), ("revenue", "revenue"), ("external_audience", "external_audience"), ("internet_accessible", "internet_accessible"), diff --git a/dojo/product/api/serializer.py b/dojo/product/api/serializer.py index 56e37d0c3a7..9b34bdb94e7 100644 --- a/dojo/product/api/serializer.py +++ b/dojo/product/api/serializer.py @@ -4,7 +4,14 @@ AuthorizedUsersMemberGuardMixin, ToolConfigurationUseGuardMixin, ) -from dojo.models import DojoMeta, Product, Product_API_Scan_Configuration +from dojo.models import ( + DojoMeta, + Product, + Product_API_Scan_Configuration, + Product_Lifecycle, + Product_Origin, + Product_Platform, +) class ProductMetaSerializer(serializers.ModelSerializer): @@ -24,9 +31,12 @@ class ProductSerializer(AuthorizedUsersMemberGuardMixin, serializers.ModelSerial findings_list = serializers.SerializerMethodField() business_criticality = serializers.ChoiceField(choices=Product.BUSINESS_CRITICALITY_CHOICES, allow_blank=True, allow_null=True, required=False) - platform = serializers.ChoiceField(choices=Product.PLATFORM_CHOICES, allow_blank=True, allow_null=True, required=False) - lifecycle = serializers.ChoiceField(choices=Product.LIFECYCLE_CHOICES, allow_blank=True, allow_null=True, required=False) - origin = serializers.ChoiceField(choices=Product.ORIGIN_CHOICES, allow_blank=True, allow_null=True, required=False) + # platform/lifecycle/origin are FKs to editable lookup tables. They are exposed over + # the API by their stable ``value`` string (SlugRelatedField), so existing clients + # and imports keep sending/receiving the same strings as before. + platform = serializers.SlugRelatedField(slug_field="value", queryset=Product_Platform.objects.all(), allow_null=True, required=False) + lifecycle = serializers.SlugRelatedField(slug_field="value", queryset=Product_Lifecycle.objects.all(), allow_null=True, required=False) + origin = serializers.SlugRelatedField(slug_field="value", queryset=Product_Origin.objects.all(), allow_null=True, required=False) product_meta = ProductMetaSerializer(read_only=True, many=True) diff --git a/dojo/product/api_v3/routes.py b/dojo/product/api_v3/routes.py index 71c2ce1870d..75c65fb9d45 100644 --- a/dojo/product/api_v3/routes.py +++ b/dojo/product/api_v3/routes.py @@ -58,7 +58,16 @@ from dojo.api_v3.pagination import list_envelope, paginate from dojo.authorization.authorization import user_has_permission from dojo.authorization.roles_permissions import Permissions -from dojo.models import Dojo_User, Endpoint, Product, Product_Type, SLA_Configuration +from dojo.models import ( + Dojo_User, + Endpoint, + Product, + Product_Lifecycle, + Product_Origin, + Product_Platform, + Product_Type, + SLA_Configuration, +) from dojo.product.api_v3.schemas import ( AssetDetail, AssetReplace, @@ -109,6 +118,21 @@ # Sentinel distinguishing "tags omitted" from "tags set to null/empty" on PATCH. _UNSET = object() +# Wire write-field -> editable option model. These arrive as the option's ``value`` string +# and are resolved to the FK row (mirrors the v2 SlugRelatedField(slug_field="value")). +_OPTION_FK_FIELDS = { + "platform": Product_Platform, + "lifecycle": Product_Lifecycle, + "origin": Product_Origin, +} + + +def _resolve_option(field: str, model: type, value: str): + option = model.objects.filter(value=value).first() + if option is None: + raise validation_problem({field: [f"{field} '{value}' is not a valid option"]}) + return option + # Derived from the row schema so a factory called with ``schema=`` documents what it # serves (I4). See ``dojo/api_v3/pagination.py::list_envelope``. @@ -157,6 +181,10 @@ def _apply_optional_relations_and_scalars(instance: Product, data: dict) -> None if sla is None: raise validation_problem({"sla_configuration": [f"SLA configuration {pk} does not exist"]}) instance.sla_configuration = sla + for wire_field, model in _OPTION_FK_FIELDS.items(): + if wire_field in data: + value = data.pop(wire_field) + setattr(instance, wire_field, _resolve_option(wire_field, model, value) if value is not None else None) for key, value in data.items(): setattr(instance, key, value) diff --git a/dojo/product/api_v3/schemas.py b/dojo/product/api_v3/schemas.py index aa2e71c0518..d14c93cc54b 100644 --- a/dojo/product/api_v3/schemas.py +++ b/dojo/product/api_v3/schemas.py @@ -34,7 +34,7 @@ class AssetSlim(Schema): django_model: ClassVar = Product - SELECT_RELATED: ClassVar[tuple] = ("prod_type",) + SELECT_RELATED: ClassVar[tuple] = ("prod_type", "lifecycle") PREFETCH_RELATED: ClassVar[tuple] = ("tags",) EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} @@ -51,6 +51,12 @@ class AssetSlim(Schema): def resolve_organization(obj) -> dict | None: return to_ref(obj.prod_type) + @staticmethod + def resolve_lifecycle(obj) -> str | None: + # lifecycle is a FK to the editable Product_Lifecycle table; the wire keeps the + # stable ``value`` string (matches API v2). + return obj.lifecycle.value if obj.lifecycle_id else None + @staticmethod def resolve_tags(obj) -> list[str]: return [t.name for t in obj.tags.all()] @@ -66,7 +72,8 @@ class AssetDetail(AssetSlim): """Slim + the documented heavier read fields (§4.5). Retrieve returns detail; list returns slim.""" # Detail fetch pulls the extra parent FKs so the ref resolvers below issue no extra queries. - SELECT_RELATED: ClassVar[tuple] = ("prod_type", "product_manager", "technical_contact", "team_manager") + SELECT_RELATED: ClassVar[tuple] = ("prod_type", "product_manager", "technical_contact", "team_manager", + "lifecycle", "platform", "origin") # Fixed joins for the user-role refs when a LIST ``?fields=`` opts up into them (§4.7 Part A). The # wire field ``asset_manager`` maps to the model FK ``product_manager`` (D11), so the join path is # declared here rather than derived from the wire name; added by the kernel only when requested. @@ -85,6 +92,14 @@ class AssetDetail(AssetSlim): technical_contact: Ref | None team_manager: Ref | None + @staticmethod + def resolve_platform(obj) -> str | None: + return obj.platform.value if obj.platform_id else None + + @staticmethod + def resolve_origin(obj) -> str | None: + return obj.origin.value if obj.origin_id else None + @staticmethod def resolve_asset_manager(obj) -> dict | None: return to_ref(obj.product_manager) diff --git a/dojo/product/models.py b/dojo/product/models.py index 0811fa80faf..9db8c86c3f1 100644 --- a/dojo/product/models.py +++ b/dojo/product/models.py @@ -99,9 +99,15 @@ class Product(BaseModel): # Metadata business_criticality = models.CharField(max_length=9, choices=BUSINESS_CRITICALITY_CHOICES, blank=True, null=True) - platform = models.CharField(max_length=11, choices=PLATFORM_CHOICES, blank=True, null=True) - lifecycle = models.CharField(max_length=12, choices=LIFECYCLE_CHOICES, blank=True, null=True) - origin = models.CharField(max_length=19, choices=ORIGIN_CHOICES, blank=True, null=True) + # platform/lifecycle/origin are customer-editable lookup tables (see + # dojo.product_attributes). The choice constants above are retained as the seed + # defaults and canonical machine values; the labels now live on the option rows. + platform = models.ForeignKey("dojo.Product_Platform", null=True, blank=True, + on_delete=models.RESTRICT, related_name="products") + lifecycle = models.ForeignKey("dojo.Product_Lifecycle", null=True, blank=True, + on_delete=models.RESTRICT, related_name="products") + origin = models.ForeignKey("dojo.Product_Origin", null=True, blank=True, + on_delete=models.RESTRICT, related_name="products") user_records = models.PositiveIntegerField(blank=True, null=True, help_text=_("Estimate the number of user records within the application.")) revenue = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True, validators=[MinValueValidator(Decimal("0.00"))], help_text=_("Estimate the application's revenue.")) external_audience = models.BooleanField(default=False, help_text=_("Specify if the application is used by people outside the organization.")) diff --git a/dojo/product/ui/filters.py b/dojo/product/ui/filters.py index 89ca863f654..fcfb022d8b7 100644 --- a/dojo/product/ui/filters.py +++ b/dojo/product/ui/filters.py @@ -20,6 +20,11 @@ from dojo.location.status import ProductLocationStatus from dojo.models import Product, Product_Type from dojo.product.queries import get_authorized_products +from dojo.product_attributes.choices import ( + lifecycle_value_choices, + origin_value_choices, + platform_value_choices, +) from dojo.product_type.queries import get_authorized_product_types labels = get_labels() @@ -90,9 +95,9 @@ class ProductFilterHelper(FilterSet): name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_LABEL) name_exact = CharFilter(field_name="name", lookup_expr="iexact", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES, null_label="Empty") - platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES, null_label="Empty") - lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, null_label="Empty") - origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES, null_label="Empty") + platform = MultipleChoiceFilter(field_name="platform__value", choices=platform_value_choices, null_label="Empty") + lifecycle = MultipleChoiceFilter(field_name="lifecycle__value", choices=lifecycle_value_choices, null_label="Empty") + origin = MultipleChoiceFilter(field_name="origin__value", choices=origin_value_choices, null_label="Empty") external_audience = BooleanFilter(field_name="external_audience") internet_accessible = BooleanFilter(field_name="internet_accessible") tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag contains") @@ -135,9 +140,9 @@ def filter_endpoints(self, queryset, name, value): ("name_exact", "name_exact"), ("prod_type__name", "prod_type__name"), ("business_criticality", "business_criticality"), - ("platform", "platform"), - ("lifecycle", "lifecycle"), - ("origin", "origin"), + ("platform__name", "platform"), + ("lifecycle__name", "lifecycle"), + ("origin__name", "origin"), ("external_audience", "external_audience"), ("internet_accessible", "internet_accessible"), ("findings_count", "findings_count"), diff --git a/dojo/product_attributes/__init__.py b/dojo/product_attributes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product_attributes/admin.py b/dojo/product_attributes/admin.py new file mode 100644 index 00000000000..707864b470b --- /dev/null +++ b/dojo/product_attributes/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform + +admin.site.register(Product_Platform) +admin.site.register(Product_Lifecycle) +admin.site.register(Product_Origin) diff --git a/dojo/product_attributes/api/__init__.py b/dojo/product_attributes/api/__init__.py new file mode 100644 index 00000000000..eedb4b52b21 --- /dev/null +++ b/dojo/product_attributes/api/__init__.py @@ -0,0 +1,3 @@ +platform_path = "product_platforms" # noqa: RUF067 +lifecycle_path = "product_lifecycles" # noqa: RUF067 +origin_path = "product_origins" # noqa: RUF067 diff --git a/dojo/product_attributes/api/serializer.py b/dojo/product_attributes/api/serializer.py new file mode 100644 index 00000000000..b21d02958e9 --- /dev/null +++ b/dojo/product_attributes/api/serializer.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform + + +class ProductAttributeOptionSerializer(serializers.ModelSerializer): + class Meta: + fields = ["id", "value", "name", "icon", "display_order"] + + def update(self, instance, validated_data): + # ``value`` is the stable machine key that the Product foreign key, imports, + # exports and automation rules depend on. It is immutable once created, so any + # attempt to change it on an existing option is ignored. + validated_data.pop("value", None) + return super().update(instance, validated_data) + + +class ProductPlatformSerializer(ProductAttributeOptionSerializer): + class Meta(ProductAttributeOptionSerializer.Meta): + model = Product_Platform + + +class ProductLifecycleSerializer(ProductAttributeOptionSerializer): + class Meta(ProductAttributeOptionSerializer.Meta): + model = Product_Lifecycle + + +class ProductOriginSerializer(ProductAttributeOptionSerializer): + class Meta(ProductAttributeOptionSerializer.Meta): + model = Product_Origin diff --git a/dojo/product_attributes/api/urls.py b/dojo/product_attributes/api/urls.py new file mode 100644 index 00000000000..e79b2291630 --- /dev/null +++ b/dojo/product_attributes/api/urls.py @@ -0,0 +1,13 @@ +from dojo.product_attributes.api import lifecycle_path, origin_path, platform_path +from dojo.product_attributes.api.views import ( + ProductLifecycleViewSet, + ProductOriginViewSet, + ProductPlatformViewSet, +) + + +def add_product_attribute_urls(router): + router.register(platform_path, ProductPlatformViewSet, basename="product_platform") + router.register(lifecycle_path, ProductLifecycleViewSet, basename="product_lifecycle") + router.register(origin_path, ProductOriginViewSet, basename="product_origin") + return router diff --git a/dojo/product_attributes/api/views.py b/dojo/product_attributes/api/views.py new file mode 100644 index 00000000000..834972cca12 --- /dev/null +++ b/dojo/product_attributes/api/views.py @@ -0,0 +1,44 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.permissions import IsAuthenticated + +from dojo.api_v2.views import DojoModelViewSet +from dojo.authorization import api_permissions as permissions +from dojo.product_attributes.api.serializer import ( + ProductLifecycleSerializer, + ProductOriginSerializer, + ProductPlatformSerializer, +) +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform + + +# Authorization: authenticated, configuration +class ProductPlatformViewSet(DojoModelViewSet): + serializer_class = ProductPlatformSerializer + queryset = Product_Platform.objects.none() + filter_backends = (DjangoFilterBackend,) + permission_classes = (IsAuthenticated, permissions.UserHasProductPlatformPermission) + + def get_queryset(self): + return Product_Platform.objects.all().order_by("display_order", "name") + + +# Authorization: authenticated, configuration +class ProductLifecycleViewSet(DojoModelViewSet): + serializer_class = ProductLifecycleSerializer + queryset = Product_Lifecycle.objects.none() + filter_backends = (DjangoFilterBackend,) + permission_classes = (IsAuthenticated, permissions.UserHasProductLifecyclePermission) + + def get_queryset(self): + return Product_Lifecycle.objects.all().order_by("display_order", "name") + + +# Authorization: authenticated, configuration +class ProductOriginViewSet(DojoModelViewSet): + serializer_class = ProductOriginSerializer + queryset = Product_Origin.objects.none() + filter_backends = (DjangoFilterBackend,) + permission_classes = (IsAuthenticated, permissions.UserHasProductOriginPermission) + + def get_queryset(self): + return Product_Origin.objects.all().order_by("display_order", "name") diff --git a/dojo/product_attributes/choices.py b/dojo/product_attributes/choices.py new file mode 100644 index 00000000000..e3cd07169c8 --- /dev/null +++ b/dojo/product_attributes/choices.py @@ -0,0 +1,26 @@ +""" +Lazy choice callables for the Product platform/lifecycle/origin filters. + +Passed as ``choices=`` to django-filter ``MultipleChoiceFilter``s so the option lists are +read from the database at request time (never at import time, which would break app +startup) and the filters keep accepting the stable ``value`` strings that clients have +always used (e.g. ``?platform=web``). +""" +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform + + +def _value_choices(model): + return [(option.value, option.name) + for option in model.objects.all().order_by("display_order", "name")] + + +def platform_value_choices(): + return _value_choices(Product_Platform) + + +def lifecycle_value_choices(): + return _value_choices(Product_Lifecycle) + + +def origin_value_choices(): + return _value_choices(Product_Origin) diff --git a/dojo/product_attributes/models.py b/dojo/product_attributes/models.py new file mode 100644 index 00000000000..0d84e8223ab --- /dev/null +++ b/dojo/product_attributes/models.py @@ -0,0 +1,73 @@ +""" +Editable lookup tables for the Asset/Product ``platform``, ``lifecycle`` and +``origin`` fields. + +These three fields used to be fixed ``CharField(choices=...)`` enums on +:class:`dojo.product.models.Product`. They are now customer-editable lookup tables, +following the same pattern as :class:`dojo.development_environment.models.Development_Environment`. + +Each option carries: + +* ``value`` -- the immutable machine string. This is what the ``Product`` foreign key + is keyed on over the API (a ``SlugRelatedField``), what the automation rules engine + compares against, and what appears in webhook payloads. It is seeded from the old + choice codes so existing API clients, imports and exports keep working unchanged. +* ``name`` -- the human-facing label shown in dropdowns and on the asset. This is the + part administrators edit. +* ``icon`` -- an optional Font Awesome class used by the classic UI display tags. +* ``display_order`` -- optional ordering for the dropdown. +""" +from django.db import models +from django.urls import reverse + + +class ProductAttributeOption(models.Model): + + """Abstract base for the three asset attribute lookup tables.""" + + # The reverse() url name of the classic-UI edit view; set by each concrete model. + edit_url_name: str = "" + + value = models.CharField( + max_length=50, + unique=True, + help_text="Stable machine value used by the API, imports and automation rules. " + "Immutable once created.", + ) + name = models.CharField( + max_length=200, + help_text="Label shown in dropdowns and on the asset.", + ) + icon = models.CharField( + max_length=100, + blank=True, + default="", + help_text="Optional Font Awesome icon class (classic UI only).", + ) + display_order = models.IntegerField( + default=0, + help_text="Optional ordering for the dropdown (lower first).", + ) + + class Meta: + abstract = True + ordering = ["display_order", "name"] + + def __str__(self): + return self.name + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse(self.edit_url_name, args=(self.id,))}] + + +class Product_Platform(ProductAttributeOption): + edit_url_name = "edit_product_platform" + + +class Product_Lifecycle(ProductAttributeOption): + edit_url_name = "edit_product_lifecycle" + + +class Product_Origin(ProductAttributeOption): + edit_url_name = "edit_product_origin" diff --git a/dojo/product_attributes/ui/__init__.py b/dojo/product_attributes/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product_attributes/ui/forms.py b/dojo/product_attributes/ui/forms.py new file mode 100644 index 00000000000..b42c6cda4b8 --- /dev/null +++ b/dojo/product_attributes/ui/forms.py @@ -0,0 +1,35 @@ +from django import forms + +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform + + +class ProductAttributeOptionForm(forms.ModelForm): + class Meta: + fields = ["value", "name", "icon", "display_order"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # ``value`` is the immutable machine key. It may be set when creating a new + # option, but never changed afterwards (existing assets, imports and rules + # reference it). + if self.instance and self.instance.pk: + self.fields["value"].disabled = True + + +class ProductPlatformForm(ProductAttributeOptionForm): + class Meta(ProductAttributeOptionForm.Meta): + model = Product_Platform + + +class ProductLifecycleForm(ProductAttributeOptionForm): + class Meta(ProductAttributeOptionForm.Meta): + model = Product_Lifecycle + + +class ProductOriginForm(ProductAttributeOptionForm): + class Meta(ProductAttributeOptionForm.Meta): + model = Product_Origin + + +class DeleteProductAttributeOptionForm(forms.Form): + id = forms.IntegerField(widget=forms.HiddenInput()) diff --git a/dojo/product_attributes/ui/urls.py b/dojo/product_attributes/ui/urls.py new file mode 100644 index 00000000000..14bcb3133fc --- /dev/null +++ b/dojo/product_attributes/ui/urls.py @@ -0,0 +1,18 @@ +from django.urls import re_path + +from dojo.product_attributes.ui import views + +urlpatterns = [ + # platforms + re_path(r"^product_platform$", views.list_platforms, name="product_platforms"), + re_path(r"^product_platform/add$", views.add_platform, name="add_product_platform"), + re_path(r"^product_platform/(?P\d+)/edit$", views.edit_platform, name="edit_product_platform"), + # lifecycles + re_path(r"^product_lifecycle$", views.list_lifecycles, name="product_lifecycles"), + re_path(r"^product_lifecycle/add$", views.add_lifecycle, name="add_product_lifecycle"), + re_path(r"^product_lifecycle/(?P\d+)/edit$", views.edit_lifecycle, name="edit_product_lifecycle"), + # origins + re_path(r"^product_origin$", views.list_origins, name="product_origins"), + re_path(r"^product_origin/add$", views.add_origin, name="add_product_origin"), + re_path(r"^product_origin/(?P\d+)/edit$", views.edit_origin, name="edit_product_origin"), +] diff --git a/dojo/product_attributes/ui/views.py b/dojo/product_attributes/ui/views.py new file mode 100644 index 00000000000..a8caf6e4e2d --- /dev/null +++ b/dojo/product_attributes/ui/views.py @@ -0,0 +1,183 @@ +""" +Classic (Django-rendered) CRUD for the three asset attribute lookup tables. + +The rich editor lives in the Vue Pro UI; these views give open-source installs and +admins a functional fallback, mirroring the Development_Environment ("Environments") +screens. All three lookups share generic list/add/edit helpers driven by ``_CONFIG``. +""" +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db.models.deletion import RestrictedError +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls import reverse +from django.utils.translation import gettext as _ + +from dojo.authorization.authorization import user_has_configuration_permission_or_403 +from dojo.product_attributes.models import Product_Lifecycle, Product_Origin, Product_Platform +from dojo.product_attributes.ui.forms import ( + DeleteProductAttributeOptionForm, + ProductLifecycleForm, + ProductOriginForm, + ProductPlatformForm, +) +from dojo.utils import add_breadcrumb, get_page_items + +logger = logging.getLogger(__name__) + +_CONFIG = { + "platform": { + "model": Product_Platform, + "form": ProductPlatformForm, + "label": "Platform", + "label_plural": "Platforms", + "model_name": "product_platform", + "list_url": "product_platforms", + "add_url": "add_product_platform", + "edit_url": "edit_product_platform", + }, + "lifecycle": { + "model": Product_Lifecycle, + "form": ProductLifecycleForm, + "label": "Lifecycle", + "label_plural": "Lifecycles", + "model_name": "product_lifecycle", + "list_url": "product_lifecycles", + "add_url": "add_product_lifecycle", + "edit_url": "edit_product_lifecycle", + }, + "origin": { + "model": Product_Origin, + "form": ProductOriginForm, + "label": "Origin", + "label_plural": "Origins", + "model_name": "product_origin", + "list_url": "product_origins", + "add_url": "add_product_origin", + "edit_url": "edit_product_origin", + }, +} + + +def _context(cfg): + return { + "label": cfg["label"], + "label_plural": cfg["label_plural"], + "model_name": cfg["model_name"], + "list_url": cfg["list_url"], + "add_url": cfg["add_url"], + "edit_url": cfg["edit_url"], + } + + +def _list(request, kind): + cfg = _CONFIG[kind] + options = get_page_items(request, cfg["model"].objects.all().order_by("display_order", "name"), 25) + add_breadcrumb(title=f"{cfg['label']} List", top_level=True, request=request) + ctx = _context(cfg) + ctx["options"] = options + return render(request, "dojo/product_attribute_list.html", ctx) + + +def _add(request, kind): + cfg = _CONFIG[kind] + user_has_configuration_permission_or_403(request.user, f"dojo.add_{cfg['model_name']}") + form = cfg["form"]() + if request.method == "POST": + form = cfg["form"](request.POST) + if form.is_valid(): + form.save() + messages.add_message(request, messages.SUCCESS, + _("%(label)s added successfully.") % {"label": cfg["label"]}, + extra_tags="alert-success") + return HttpResponseRedirect(reverse(cfg["list_url"])) + add_breadcrumb(title=f"Add {cfg['label']}", top_level=False, request=request) + ctx = _context(cfg) + ctx["form"] = form + return render(request, "dojo/product_attribute_add.html", ctx) + + +def _edit(request, kind, pk): + cfg = _CONFIG[kind] + option = get_object_or_404(cfg["model"], pk=pk) + form = cfg["form"](instance=option) + delete_form = DeleteProductAttributeOptionForm(initial={"id": option.id}) + edit_key = f"edit_{cfg['model_name']}" + delete_key = f"delete_{cfg['model_name']}" + if request.method == "POST" and request.POST.get(edit_key): + user_has_configuration_permission_or_403(request.user, f"dojo.change_{cfg['model_name']}") + form = cfg["form"](request.POST, instance=option) + if form.is_valid(): + form.save() + messages.add_message(request, messages.SUCCESS, + _("%(label)s updated successfully.") % {"label": cfg["label"]}, + extra_tags="alert-success") + return HttpResponseRedirect(reverse(cfg["list_url"])) + if request.method == "POST" and request.POST.get(delete_key): + user_has_configuration_permission_or_403(request.user, f"dojo.delete_{cfg['model_name']}") + try: + option.delete() + messages.add_message(request, messages.SUCCESS, + _("%(label)s deleted successfully.") % {"label": cfg["label"]}, + extra_tags="alert-success") + except RestrictedError as err: + messages.add_message(request, messages.WARNING, + f"{cfg['label']} cannot be deleted: {err}", + extra_tags="alert-warning") + return HttpResponseRedirect(reverse(cfg["list_url"])) + add_breadcrumb(title=f"Edit {cfg['label']}", top_level=False, request=request) + ctx = _context(cfg) + ctx["form"] = form + ctx["delete_form"] = delete_form + ctx["option"] = option + return render(request, "dojo/product_attribute_edit.html", ctx) + + +# ---- platform ---- +@login_required +def list_platforms(request): + return _list(request, "platform") + + +@login_required +def add_platform(request): + return _add(request, "platform") + + +@login_required +def edit_platform(request, pk): + return _edit(request, "platform", pk) + + +# ---- lifecycle ---- +@login_required +def list_lifecycles(request): + return _list(request, "lifecycle") + + +@login_required +def add_lifecycle(request): + return _add(request, "lifecycle") + + +@login_required +def edit_lifecycle(request, pk): + return _edit(request, "lifecycle", pk) + + +# ---- origin ---- +@login_required +def list_origins(request): + return _list(request, "origin") + + +@login_required +def add_origin(request): + return _add(request, "origin") + + +@login_required +def edit_origin(request, pk): + return _edit(request, "origin", pk) diff --git a/dojo/templates/dojo/product_attribute_add.html b/dojo/templates/dojo/product_attribute_add.html new file mode 100644 index 00000000000..f7efeb2d8ee --- /dev/null +++ b/dojo/templates/dojo/product_attribute_add.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} + {{ block.super }} +

{% blocktrans %}Register a new {{ label }}{% endblocktrans %}

+
{% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
+
+ +
+
+
+{% endblock %} diff --git a/dojo/templates/dojo/product_attribute_edit.html b/dojo/templates/dojo/product_attribute_edit.html new file mode 100644 index 00000000000..f221f33ed37 --- /dev/null +++ b/dojo/templates/dojo/product_attribute_edit.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load authorization_tags %} +{% block content %} + {{ block.super }} +

{% blocktrans %}Edit {{ label }}{% endblocktrans %} {{ option.name }}

+
{% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} + {{ delete_form }} +
+
+ + {% with delete_perm="dojo.delete_"|add:model_name %} + {% if delete_perm|has_configuration_permission:request %} + + {% endif %} + {% endwith %} +
+
+
+{% endblock %} diff --git a/dojo/templates/dojo/product_attribute_list.html b/dojo/templates/dojo/product_attribute_list.html new file mode 100644 index 00000000000..5bd636ee51e --- /dev/null +++ b/dojo/templates/dojo/product_attribute_list.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% load i18n %} +{% load authorization_tags %} +{% block content %} + {{ block.super }} +
+
+
+
+

+ {{ label_plural }} + +

+
+
+ {% if options %} +
+ {% include "dojo/paging_snippet.html" with page=options page_size=True %} +
+
+ + + + + + + + + {% with change_perm="dojo.change_"|add:model_name %} + {% for option in options %} + + {% if change_perm|has_configuration_permission:request %} + + {% else %} + + {% endif %} + + + {% endfor %} + {% endwith %} + +
{% trans "Label" %}{% trans "Value" %}
{% if option.icon %} {% endif %}{{ option.name }}{% if option.icon %} {% endif %}{{ option.name }}{{ option.value }}
+
+
+ {% include "dojo/paging_snippet.html" with page=options page_size=True %} +
+ {% else %} +

{% blocktrans %}No {{ label_plural }} found.{% endblocktrans %}

+ {% endif %} +
+
+{% endblock %} diff --git a/dojo/templates/dojo/view_product_details.html b/dojo/templates/dojo/view_product_details.html index 58b15c6e586..45b38db8972 100644 --- a/dojo/templates/dojo/view_product_details.html +++ b/dojo/templates/dojo/view_product_details.html @@ -369,15 +369,15 @@

{% trans "Platform" %} - {{ prod.get_platform_display|notspecified }} + {{ prod.platform.name|notspecified }} {% trans "Lifecycle" %} - {{ prod.get_lifecycle_display|notspecified }} + {{ prod.lifecycle.name|notspecified }} {% trans "Origin" %} - {{ prod.get_origin_display|notspecified }} + {{ prod.origin.name|notspecified }} {% trans "User Records" %} diff --git a/dojo/templatetags/display_tags.py b/dojo/templatetags/display_tags.py index 8b8ac4730ad..b9dba076a98 100644 --- a/dojo/templatetags/display_tags.py +++ b/dojo/templatetags/display_tags.py @@ -608,47 +608,30 @@ def last_value(value): return value +def _option_icon(option): + # option is a Product_Platform / Product_Lifecycle / Product_Origin instance (or None). + # The icon and label now live on the editable option row; fall back to the label text + # for custom options that have no icon configured. + if not option: + return "" + if option.icon: + return mark_safe(icon(option.icon, option.name)) + return option.name + + @register.filter def platform_icon(value): - if value == Product.WEB_PLATFORM: - return mark_safe(icon("list-alt", "Web")) - if value == Product.DESKTOP_PLATFORM: - return mark_safe(icon("desktop", "Desktop")) - if value == Product.MOBILE_PLATFORM: - return mark_safe(icon("mobile", "Mobile")) - if value == Product.WEB_SERVICE_PLATFORM: - return mark_safe(icon("plug", "Web Service")) - if value == Product.IOT: - return mark_safe(icon("random", "Internet of Things")) - return "" # mark_safe(not_specified_icon('Platform Not Specified')) + return _option_icon(value) @register.filter def lifecycle_icon(value): - if value == Product.CONSTRUCTION: - return mark_safe(icon("compass", "Explore")) - if value == Product.PRODUCTION: - return mark_safe(icon("ship", "Sustain")) - if value == Product.RETIREMENT: - return mark_safe(icon("moon-o", "Retire")) - return "" # mark_safe(not_specified_icon('Lifecycle Not Specified')) + return _option_icon(value) @register.filter def origin_icon(value): - if value == Product.THIRD_PARTY_LIBRARY_ORIGIN: - return mark_safe(icon("book", "Third-Party Library")) - if value == Product.PURCHASED_ORIGIN: - return mark_safe(icon("money", "Purchased")) - if value == Product.CONTRACTOR_ORIGIN: - return mark_safe(icon("suitcase", "Contractor Developed")) - if value == Product.INTERNALLY_DEVELOPED_ORIGIN: - return mark_safe(icon("home", "Internally Developed")) - if value == Product.OPEN_SOURCE_ORIGIN: - return mark_safe(icon("code", "Open Source")) - if value == Product.OUTSOURCED_ORIGIN: - return mark_safe(icon("globe", "Outsourced")) - return "" # mark_safe(not_specified_icon('Origin Not Specified')) + return _option_icon(value) @register.filter diff --git a/dojo/urls.py b/dojo/urls.py index e484c26e8f3..c9985e2cdb3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -64,6 +64,8 @@ from dojo.organization.api.urls import add_organization_urls from dojo.organization.urls import urlpatterns as organization_urls from dojo.product.api.urls import add_product_urls +from dojo.product_attributes.api.urls import add_product_attribute_urls +from dojo.product_attributes.ui.urls import urlpatterns as product_attribute_urls from dojo.product_type.api.urls import add_product_type_urls from dojo.regulations.api.urls import add_regulations_urls from dojo.regulations.ui.urls import urlpatterns as regulations @@ -104,6 +106,7 @@ v2_api = add_announcement_urls(v2_api) v2_api.register(r"configuration_permissions", ConfigurationPermissionViewSet, basename="permission") v2_api = add_development_environment_urls(v2_api) +v2_api = add_product_attribute_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # dojo_groups, dojo_group_members → pro/groups, pro/group_members v2_api = register_endpoint_meta_import(v2_api) @@ -167,6 +170,7 @@ ur = [] ur += asset_urls ur += dev_env_urls +ur += product_attribute_urls ur += eng_urls ur += finding_urls ur += finding_group_urls diff --git a/unittests/api_v3/test_apiv3_assets.py b/unittests/api_v3/test_apiv3_assets.py index 55df5ceef6a..38669d8c6ca 100644 --- a/unittests/api_v3/test_apiv3_assets.py +++ b/unittests/api_v3/test_apiv3_assets.py @@ -6,7 +6,7 @@ from django.db import connection from django.test.utils import CaptureQueriesContext -from dojo.models import Dojo_User, Product, Product_Type, User +from dojo.models import Dojo_User, Product, Product_Lifecycle, Product_Origin, Product_Platform, Product_Type, User from .base import ApiV3TestCase @@ -116,7 +116,7 @@ def test_create_happy_path(self): self.assertEqual("v3 created asset", body["name"]) self.assertEqual(pt.id, body["organization"]["id"]) created = Product.objects.get(name="v3 created asset") - self.assertEqual("production", created.lifecycle) + self.assertEqual("production", created.lifecycle.value) self.assertEqual({"pci", "v3"}, {t.name for t in created.tags.all()}) def test_create_missing_required_is_400(self): @@ -168,6 +168,11 @@ def _make_asset(self, **kwargs): pt = Product_Type.objects.first() defaults = {"name": "v3 put asset", "description": "old", "prod_type": pt, "sla_configuration_id": 1} defaults.update(kwargs) + # platform/lifecycle/origin are FKs to editable option tables; resolve any value string + # kwarg to its option row (mirrors the API's SlugRelatedField(slug_field="value")). + for field, model in (("platform", Product_Platform), ("lifecycle", Product_Lifecycle), ("origin", Product_Origin)): + if isinstance(defaults.get(field), str): + defaults[field] = model.objects.get(value=defaults[field]) return Product.objects.create(**defaults), pt def test_put_full_replace_resets_omitted_optionals(self): diff --git a/unittests/api_v3/test_apiv3_authz_static.py b/unittests/api_v3/test_apiv3_authz_static.py index b8c70bc0ad4..9c46fa350b4 100644 --- a/unittests/api_v3/test_apiv3_authz_static.py +++ b/unittests/api_v3/test_apiv3_authz_static.py @@ -74,6 +74,11 @@ def _scanned_files() -> list[Path]: ("dojo/user/api_v3/routes.py", "Dojo_User.objects.filter"): "the RBAC scoping itself: the self-only fallback queryset for users lacking auth.view_user " "(line 100) and the username-uniqueness check AFTER the auth.add_user gate (line 181).", + ("dojo/product/api_v3/routes.py", "model.objects.filter"): + "resolves an editable attribute option (Product_Platform/Lifecycle/Origin) by its immutable " + "``value`` slug during an asset write; a global configuration lookup table with no per-user " + "authz scope (like Development_Environment), reached only AFTER the Product_Type_Add_Product / " + "Product_Edit gate -- not an object read.", } # --- Rule B: authorization primitives --------------------------------------------------------- diff --git a/unittests/test_product_attributes.py b/unittests/test_product_attributes.py new file mode 100644 index 00000000000..af59062ae87 --- /dev/null +++ b/unittests/test_product_attributes.py @@ -0,0 +1,103 @@ +""" +Tests for the editable Asset attribute lookup tables (platform/lifecycle/origin). + +Covers the models and their seeded defaults, the /api/v2 CRUD endpoints (including the immutability +of ``value``), and that the Product API keeps exposing/accepting the option's ``value`` string via +SlugRelatedField after the CharField -> ForeignKey conversion. +""" +from django.test import override_settings +from django.urls import reverse +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from dojo.models import Product, Product_Lifecycle, Product_Origin, Product_Platform, Product_Type, SLA_Configuration +from unittests.dojo_test_case import DojoAPITestCase, versioned_fixtures + + +@versioned_fixtures +@override_settings(SECURE_SSL_REDIRECT=False) +class ProductAttributeModelTest(DojoAPITestCase): + fixtures = ["dojo_testdata.json"] + + def test_defaults_are_seeded(self): + self.assertEqual(Product_Platform.objects.filter(value="web service").first().name, "API") + self.assertEqual(Product_Lifecycle.objects.filter(value="production").count(), 1) + self.assertEqual(Product_Origin.objects.filter(value="internal").first().name, "Internally Developed") + + +@versioned_fixtures +@override_settings(SECURE_SSL_REDIRECT=False) +class ProductAttributeApiTest(DojoAPITestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + token = Token.objects.get(user__username="admin") + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + def test_list_platforms(self): + response = self.client.get("/api/v2/product_platforms/", format="json") + self.assertEqual(response.status_code, 200, response.content[:500]) + values = {row["value"] for row in response.json()["results"]} + self.assertIn("web service", values) + + def test_create_and_value_immutable(self): + created = self.client.post( + "/api/v2/product_lifecycles/", + data={"value": "beta", "name": "Beta"}, format="json", + ) + self.assertEqual(created.status_code, 201, created.content[:500]) + option_id = created.json()["id"] + # value is immutable on update; only the label changes. + updated = self.client.patch( + f"/api/v2/product_lifecycles/{option_id}/", + data={"name": "Beta Renamed", "value": "gamma"}, format="json", + ) + self.assertEqual(updated.status_code, 200, updated.content[:500]) + option = Product_Lifecycle.objects.get(id=option_id) + self.assertEqual(option.name, "Beta Renamed") + self.assertEqual(option.value, "beta") + + def test_product_exposes_and_accepts_value_string(self): + prod_type = Product_Type.objects.first() + sla = SLA_Configuration.objects.first() + # Write path: the API accepts the option's value string. + response = self.client.post( + reverse("product-list"), + data={ + "name": "pa-wire-compat", "description": "d", + "prod_type": prod_type.id, "sla_configuration": sla.id, + "platform": "web", "lifecycle": "production", "origin": "internal", + }, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content[:1000]) + body = response.json() + # Read path: the value string comes back unchanged. + self.assertEqual(body["platform"], "web") + self.assertEqual(body["lifecycle"], "production") + self.assertEqual(body["origin"], "internal") + product = Product.objects.get(id=body["id"]) + self.assertEqual(product.platform.value, "web") + + def test_unknown_value_is_rejected(self): + prod_type = Product_Type.objects.first() + sla = SLA_Configuration.objects.first() + response = self.client.post( + reverse("product-list"), + data={ + "name": "pa-bad", "description": "d", + "prod_type": prod_type.id, "sla_configuration": sla.id, + "platform": "does-not-exist", + }, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content[:500]) + + def test_delete_in_use_option_is_blocked(self): + prod_type = Product_Type.objects.first() + platform = Product_Platform.objects.create(value="in-use", name="In Use") + Product.objects.create(name="pa-blocks-delete", description="d", prod_type=prod_type, platform=platform) + response = self.client.delete(f"/api/v2/product_platforms/{platform.id}/") + self.assertGreaterEqual(response.status_code, 400) + self.assertTrue(Product_Platform.objects.filter(id=platform.id).exists())