From ed803abd733582f4a187d5149fac636bf8c4e381 Mon Sep 17 00:00:00 2001 From: devGregA <4741312+devGregA@users.noreply.github.com> Date: Sat, 9 May 2026 16:35:05 -0600 Subject: [PATCH] Tailwind UI rebuild, legacy authorization, OS surface removals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand the authorization layer off to dojo-pro. OS keeps a legacy ``is_superuser`` / ``is_staff`` / ``authorized_users`` model and the seven RBAC + Dojo_Group classes survive as ``managed=False`` shells in ``dojo/authorization/models.py`` so historical pro migrations (``pro.0001_plugiun_consolidation`` ``EnhancedDojoGroup.group``, ``pro.0034_pghistory_for_permissions_models`` proxy bases) keep resolving when Django reloads project state. OS code makes no runtime references to Pro. Single ``dojo.0268_release_authorization_to_pro`` migration folds: - Re-introduces ``authorized_users`` M2M on Product / Product_Type and backfills it from the RBAC tables (Member / Group → flat membership; Global_Role(Owner|Writer|Maintainer|API_Importer) → ``is_superuser`` / ``is_staff``). - Drops the redundant post-RBAC ``members`` / ``authorization_groups`` M2M accessors on Product / Product_Type (the through-tables remain). - Flips the eight authorization shells to ``managed=False`` and pins their ``db_table``s. - ``RemoveField``s ``default_group`` / ``default_group_role`` / ``default_group_email_pattern`` from ``dojo_system_settings`` (Pro copies the values onto ``EnhancedSystemSettings`` first via ``run_before``). Plus the Tailwind UI rebuild and the OS surface tidying that this branch was already carrying. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration-tests.yml | 3 - Dockerfile.nginx-alpine | 2 + components/node_modules/.gitkeep | 0 components/package.json | 18 +- components/tailwind.css | 1245 +++++++ components/yarn.lock | 552 ++- docs/content/admin/sso/OS__auth0.md | 36 - docs/content/admin/sso/OS__azure_ad.md | 72 - .../admin/sso/OS__github_enterprise.md | 35 - docs/content/admin/sso/OS__gitlab.md | 45 - docs/content/admin/sso/OS__google.md | 59 - docs/content/admin/sso/OS__keycloak.md | 74 - docs/content/admin/sso/OS__oidc.md | 40 - docs/content/admin/sso/OS__okta.md | 46 - docs/content/admin/sso/OS__remote_user.md | 37 - docs/content/admin/sso/OS__saml.md | 80 - docs/content/admin/sso/PRO__auth0.md | 2 +- docs/content/admin/sso/PRO__azure_ad.md | 2 +- .../admin/sso/PRO__github_enterprise.md | 2 +- docs/content/admin/sso/PRO__gitlab.md | 2 +- docs/content/admin/sso/PRO__google.md | 2 +- docs/content/admin/sso/PRO__keycloak.md | 2 +- docs/content/admin/sso/PRO__oidc.md | 2 +- docs/content/admin/sso/PRO__okta.md | 2 +- docs/content/admin/sso/PRO__saml.md | 2 +- docs/content/admin/sso/_index.md | 40 +- .../user_management/OS__authorized_users.md | 60 + docs/content/admin/user_management/_index.md | 29 +- .../user_management/about_perms_and_roles.md | 6 +- .../admin/user_management/configure_sso.md | 750 ----- .../user_management/create_user_group.md | 6 +- .../user_management/set_user_permissions.md | 5 +- .../user_management/user_permission_chart.md | 6 +- docs/content/automation/api/api-v2-docs.md | 2 +- docs/content/releases/os_upgrading/2.59.md | 50 +- docs/content/releases/pro/changelog.md | 7 + dojo/announcement/views.py | 4 - dojo/api_v2/permissions.py | 1280 +------ dojo/api_v2/serializers.py | 400 +-- dojo/api_v2/views.py | 289 +- dojo/apps.py | 5 + dojo/asset/api/filters.py | 18 - dojo/asset/api/serializers.py | 93 +- dojo/asset/api/urls.py | 6 +- dojo/asset/api/views.py | 70 +- dojo/asset/urls.py | 60 +- dojo/auditlog/filters.py | 5 +- dojo/auditlog/ui/views.py | 17 +- dojo/authorization/MIGRATION_REHEARSAL.md | 247 ++ dojo/authorization/__init__.py | 7 + dojo/authorization/api_permissions.py | 1120 ++++++ dojo/authorization/authorization.py | 486 +-- dojo/authorization/middleware.py | 50 + dojo/authorization/models.py | 161 + dojo/authorization/query_filters.py | 15 + dojo/authorization/query_registrations.py | 447 +++ dojo/authorization/roles_permissions.py | 432 +-- dojo/authorization/template_filters.py | 47 + dojo/authorization/url_permissions.py | 295 ++ dojo/banner/views.py | 4 - dojo/benchmark/views.py | 6 - dojo/components/views.py | 3 +- dojo/context_processors.py | 27 +- .../0267_usercontactinfo_ui_use_tailwind.py | 18 + .../0268_release_authorization_to_pro.py | 240 ++ dojo/development_environment/views.py | 3 - dojo/endpoint/queries.py | 187 +- dojo/endpoint/views.py | 26 +- dojo/engagement/queries.py | 50 +- dojo/engagement/views.py | 46 +- dojo/filters.py | 80 +- dojo/finding/queries.py | 191 +- dojo/finding/views.py | 71 +- dojo/finding_group/queries.py | 97 +- dojo/finding_group/views.py | 17 +- dojo/fixtures/defect_dojo_sample_data.json | 3 - .../defect_dojo_sample_data_locations.json | 3 - dojo/fixtures/dojo_testdata.json | 2 +- dojo/forms.py | 380 +-- dojo/github/templates/dojo/delete_github.html | 2 +- dojo/github/templates/dojo/new_github.html | 2 +- dojo/github/ui/views.py | 4 - dojo/group/__init__.py | 0 dojo/group/queries.py | 69 - dojo/group/urls.py | 17 - dojo/group/utils.py | 68 - dojo/group/views.py | 592 ---- dojo/home/views.py | 5 +- dojo/importers/auto_create_context.py | 30 +- dojo/jira/api/views.py | 7 +- dojo/jira/queries.py | 116 +- dojo/location/api/endpoint_compat.py | 13 +- dojo/location/api/permissions.py | 46 - dojo/location/api/views.py | 15 +- dojo/location/queries.py | 144 +- .../commands/migrate_staff_users.py | 94 - dojo/metrics/utils.py | 9 +- dojo/metrics/views.py | 13 +- dojo/models.py | 100 +- dojo/note_type/views.py | 6 - dojo/notes/views.py | 13 +- dojo/notifications/helper.py | 14 +- .../add_notification_webhook.html | 2 +- .../templates/notifications/alerts.html | 1 - .../notifications/delete_alerts.html | 2 +- .../delete_notification_webhook.html | 2 +- .../edit_notification_webhook.html | 2 +- .../notifications/notifications.html | 9 +- .../view_notification_webhooks.html | 2 +- dojo/notifications/ui/views.py | 4 +- dojo/object/views.py | 6 - dojo/organization/api/filters.py | 24 +- dojo/organization/api/serializers.py | 109 +- dojo/organization/api/urls.py | 7 +- dojo/organization/api/views.py | 109 +- dojo/organization/urls.py | 60 +- dojo/product/queries.py | 358 +- dojo/product/views.py | 293 +- dojo/product_type/queries.py | 125 +- dojo/product_type/views.py | 274 +- dojo/regulations/views.py | 3 - dojo/reports/views.py | 31 +- dojo/risk_acceptance/api.py | 5 +- dojo/risk_acceptance/queries.py | 50 +- dojo/search/views.py | 17 +- dojo/settings/settings.dist.py | 77 +- dojo/sla_config/views.py | 4 - dojo/sso/__init__.py | 0 dojo/sso/attribute_maps/__init__.py | 0 dojo/sso/attribute_maps/django_saml_uri.py | 19 - dojo/sso/attribute_maps/saml_uri.py | 243 -- dojo/sso/context_processors.py | 23 - dojo/sso/middleware.py | 35 - dojo/sso/pipeline.py | 188 -- dojo/sso/remote_user.py | 110 - dojo/sso/settings.py | 455 --- .../sso/templates/dojo/sso_login_buttons.html | 56 - dojo/sso/urls.py | 10 - dojo/sso/views.py | 43 - dojo/static/dojo/css/classic/dojo.css | 1949 +++++++++++ dojo/static/dojo/css/datatables-dd.css | 466 +++ dojo/static/dojo/css/dojo.css | 916 +++-- .../work-sans-latin-ext-wght-normal.woff2 | Bin 0 -> 35716 bytes .../files/work-sans-latin-wght-normal.woff2 | Bin 0 -> 50316 bytes .../work-sans-vietnamese-wght-normal.woff2 | Bin 0 -> 11800 bytes dojo/static/dojo/css/tailwind-out.css | 2 + dojo/static/dojo/js/classic/index.js | 354 ++ dojo/static/dojo/js/classic/metrics.js | 2465 ++++++++++++++ dojo/static/dojo/js/index.js | 554 ++- dojo/static/dojo/js/metrics.js | 2995 ++++------------- dojo/static/dojo/js/vendor/alpine.min.js | 5 + dojo/static/dojo/js/vendor/htmx.min.js | 1 + dojo/survey/views.py | 28 +- dojo/system_settings/views.py | 9 +- dojo/template_loaders.py | 82 + dojo/templates/base.html | 1252 +++---- .../add_choices.html | 5 +- .../add_engagement.html | 2 +- .../add_survey.html | 2 +- .../assign_survey.html | 2 +- .../create_questionnaire.html | 2 +- .../create_related_question.html | 45 +- .../delete_questionnaire.html | 2 +- .../edit_question.html | 9 +- .../edit_survey_questions.html | 2 +- .../existing_engagement.html | 2 +- .../list_surveys.html | 55 +- .../defectDojo-engagement-survey/surveys.html | 11 +- .../view_survey.html | 5 +- dojo/templates/dojo/ad_hoc_findings.html | 13 +- dojo/templates/dojo/add_endpoint.html | 8 +- dojo/templates/dojo/add_findings.html | 17 +- dojo/templates/dojo/add_group.html | 68 - dojo/templates/dojo/add_note_type.html | 2 +- .../add_product_api_scan_configuration.html | 2 +- dojo/templates/dojo/add_related.html | 38 +- dojo/templates/dojo/add_risk_acceptance.html | 11 +- dojo/templates/dojo/add_template.html | 15 +- dojo/templates/dojo/add_tests.html | 8 +- dojo/templates/dojo/add_user.html | 6 +- dojo/templates/dojo/announcement.html | 2 +- .../dojo/apply_finding_template.html | 11 +- .../apply_finding_template_form_fields.html | 4 +- .../authorize_user_for_product_types.html | 14 + .../dojo/authorize_user_for_products.html | 14 + dojo/templates/dojo/banner.html | 2 +- dojo/templates/dojo/benchmark.html | 44 +- dojo/templates/dojo/change_pwd.html | 2 +- dojo/templates/dojo/checklist.html | 2 +- dojo/templates/dojo/clear_finding_review.html | 8 +- dojo/templates/dojo/close_finding.html | 2 +- dojo/templates/dojo/copy_object.html | 8 +- dojo/templates/dojo/dashboard-metrics.html | 16 +- dojo/templates/dojo/dashboard.html | 127 +- .../templates/dojo/defect_finding_review.html | 2 +- dojo/templates/dojo/delete_benchmark.html | 2 +- dojo/templates/dojo/delete_endpoint.html | 2 +- dojo/templates/dojo/delete_engagement.html | 2 +- dojo/templates/dojo/delete_finding_group.html | 2 +- dojo/templates/dojo/delete_group.html | 28 - dojo/templates/dojo/delete_jira.html | 2 +- dojo/templates/dojo/delete_object.html | 2 +- dojo/templates/dojo/delete_presets.html | 2 +- dojo/templates/dojo/delete_product.html | 2 +- ...delete_product_api_scan_configuration.html | 2 +- dojo/templates/dojo/delete_product_group.html | 12 - dojo/templates/dojo/delete_product_type.html | 2 +- .../dojo/delete_product_type_group.html | 13 - dojo/templates/dojo/delete_technology.html | 2 +- dojo/templates/dojo/delete_test.html | 2 +- dojo/templates/dojo/delete_tool_product.html | 2 +- dojo/templates/dojo/delete_user.html | 2 +- dojo/templates/dojo/disable_note_type.html | 2 +- dojo/templates/dojo/dismiss_announcement.html | 2 +- dojo/templates/dojo/edit_dev_env.html | 2 +- dojo/templates/dojo/edit_endpoint.html | 8 +- dojo/templates/dojo/edit_finding.html | 17 +- dojo/templates/dojo/edit_group_member.html | 12 - dojo/templates/dojo/edit_jira.html | 2 +- dojo/templates/dojo/edit_metadata.html | 2 +- dojo/templates/dojo/edit_note.html | 2 +- dojo/templates/dojo/edit_note_type.html | 2 +- dojo/templates/dojo/edit_object.html | 2 +- dojo/templates/dojo/edit_presets.html | 11 +- dojo/templates/dojo/edit_product.html | 14 +- .../edit_product_api_scan_configuration.html | 2 +- dojo/templates/dojo/edit_product_group.html | 12 - dojo/templates/dojo/edit_product_type.html | 11 +- .../dojo/edit_product_type_group.html | 13 - dojo/templates/dojo/edit_regulation.html | 2 +- dojo/templates/dojo/edit_sla_config.html | 2 +- dojo/templates/dojo/edit_technology.html | 2 +- dojo/templates/dojo/edit_test.html | 8 +- dojo/templates/dojo/edit_test_type.html | 2 +- dojo/templates/dojo/edit_tool_config.html | 2 +- dojo/templates/dojo/edit_tool_product.html | 2 +- dojo/templates/dojo/edit_tool_type.html | 2 +- dojo/templates/dojo/enable_note_type.html | 2 +- .../dojo/endpoint_meta_importer.html | 2 +- dojo/templates/dojo/endpoint_pdf_report.html | 10 +- dojo/templates/dojo/endpoints.html | 10 +- dojo/templates/dojo/engagement.html | 8 +- .../templates/dojo/engagement_pdf_report.html | 10 +- dojo/templates/dojo/engagements_all.html | 10 +- dojo/templates/dojo/filter_js_snippet.html | 10 +- dojo/templates/dojo/filter_snippet.html | 78 +- .../dojo/finding_groups_list_snippet.html | 1 - dojo/templates/dojo/finding_pdf_report.html | 10 +- .../dojo/finding_related_actions.html | 2 +- .../templates/dojo/findings_list_snippet.html | 74 +- dojo/templates/dojo/form_fields.html | 13 +- dojo/templates/dojo/groups.html | 107 - dojo/templates/dojo/import_scan_results.html | 10 +- dojo/templates/dojo/login.html | 160 +- dojo/templates/dojo/manage_files.html | 8 +- dojo/templates/dojo/manage_images.html | 2 +- dojo/templates/dojo/merge_findings.html | 11 +- dojo/templates/dojo/metrics.html | 59 +- dojo/templates/dojo/migrate_endpoints.html | 2 +- dojo/templates/dojo/new_dev_env.html | 2 +- dojo/templates/dojo/new_eng.html | 13 +- dojo/templates/dojo/new_group_member.html | 17 - .../templates/dojo/new_group_member_user.html | 18 - dojo/templates/dojo/new_jira.html | 2 +- dojo/templates/dojo/new_jira_advanced.html | 2 +- dojo/templates/dojo/new_object.html | 2 +- dojo/templates/dojo/new_params.html | 11 +- dojo/templates/dojo/new_product.html | 11 +- ...html => new_product_authorized_users.html} | 2 +- dojo/templates/dojo/new_product_group.html | 17 - .../dojo/new_product_group_group.html | 17 - dojo/templates/dojo/new_product_type.html | 11 +- .../new_product_type_authorized_users.html | 14 + .../dojo/new_product_type_group.html | 18 - .../dojo/new_product_type_group_group.html | 17 - dojo/templates/dojo/new_regulation.html | 2 +- dojo/templates/dojo/new_sla_config.html | 2 +- dojo/templates/dojo/new_tech.html | 2 +- dojo/templates/dojo/new_test_type.html | 2 +- dojo/templates/dojo/new_tool_config.html | 2 +- dojo/templates/dojo/new_tool_product.html | 2 +- dojo/templates/dojo/new_tool_type.html | 2 +- .../dojo/partials/alerts_dropdown.html | 16 + dojo/templates/dojo/product.html | 26 +- .../dojo/product_endpoint_pdf_report.html | 14 +- dojo/templates/dojo/product_metrics.html | 21 +- dojo/templates/dojo/product_pdf_report.html | 14 +- dojo/templates/dojo/product_type.html | 10 +- .../dojo/product_type_pdf_report.html | 10 +- dojo/templates/dojo/profile.html | 59 +- dojo/templates/dojo/remediation_date.html | 2 +- dojo/templates/dojo/report_builder.html | 11 +- .../templates/dojo/report_filter_snippet.html | 4 - dojo/templates/dojo/report_widget.html | 2 +- dojo/templates/dojo/review_finding.html | 8 +- dojo/templates/dojo/simple_search.html | 76 +- dojo/templates/dojo/snippets/comments.html | 12 +- dojo/templates/dojo/snippets/empty_state.html | 21 + dojo/templates/dojo/snippets/endpoints.html | 12 +- .../dojo/snippets/engagement_list.html | 8 +- .../risk_acceptance_actions_snippet.html | 10 +- .../risk_acceptance_actions_snippet_js.html | 23 +- .../dojo/snippets/sonarqube_history.html | 2 +- dojo/templates/dojo/support.html | 30 +- dojo/templates/dojo/system_settings.html | 10 +- dojo/templates/dojo/templates.html | 14 +- dojo/templates/dojo/test_pdf_report.html | 10 +- dojo/templates/dojo/up_threat.html | 2 +- dojo/templates/dojo/url/create.html | 8 +- dojo/templates/dojo/url/delete.html | 2 +- dojo/templates/dojo/url/list.html | 10 +- dojo/templates/dojo/url/update.html | 8 +- dojo/templates/dojo/url/view.html | 17 +- dojo/templates/dojo/users.html | 9 +- dojo/templates/dojo/view_endpoint.html | 17 +- dojo/templates/dojo/view_eng.html | 152 +- dojo/templates/dojo/view_engagements.html | 1 - dojo/templates/dojo/view_engineer.html | 11 +- dojo/templates/dojo/view_finding.html | 128 +- dojo/templates/dojo/view_finding_group.html | 2 +- dojo/templates/dojo/view_group.html | 382 --- dojo/templates/dojo/view_note_history.html | 2 +- dojo/templates/dojo/view_objects.html | 7 +- dojo/templates/dojo/view_objects_eng.html | 1 - dojo/templates/dojo/view_presets.html | 4 +- dojo/templates/dojo/view_product_details.html | 233 +- dojo/templates/dojo/view_product_type.html | 163 +- dojo/templates/dojo/view_risk_acceptance.html | 27 +- dojo/templates/dojo/view_test.html | 175 +- dojo/templates/dojo/view_user.html | 186 +- dojo/templates/login/forgot_username.html | 2 +- dojo/templates/login/password_reset.html | 2 +- .../login/password_reset_confirm.html | 2 +- dojo/templates_classic/400.html | 17 + dojo/templates_classic/403.html | 17 + dojo/templates_classic/404.html | 17 + dojo/templates_classic/500.html | 17 + dojo/templates_classic/base.html | 1254 +++++++ .../add_choices.html | 18 + .../add_engagement.html | 14 + .../add_survey.html | 26 + .../add_surveys.html | 3 + .../answer_survey.html | 25 + .../assign_survey.html | 14 + .../create_questionnaire.html | 27 + .../create_related_question.html | 88 + .../delete_questionnaire.html | 46 + .../edit_question.html | 30 + .../edit_survey_questions.html | 30 + .../existing_engagement.html | 15 + .../list_questions.html | 85 + .../list_surveys.html | 170 + .../survey_fields.html | 95 + .../defectDojo-engagement-survey/surveys.html | 97 + .../view_survey.html | 23 + dojo/templates_classic/disabled.html | 9 + .../dojo/action_history.html | 193 ++ .../dojo/ad_hoc_findings.html | 92 + dojo/templates_classic/dojo/add_endpoint.html | 31 + dojo/templates_classic/dojo/add_findings.html | 159 + .../dojo/add_findings_as_accepted.html | 56 + .../templates_classic/dojo/add_note_type.html | 13 + .../dojo/add_notification_webhook.html | 13 + .../add_product_api_scan_configuration.html | 35 + dojo/templates_classic/dojo/add_related.html | 100 + .../dojo/add_risk_acceptance.html | 69 + dojo/templates_classic/dojo/add_template.html | 132 + dojo/templates_classic/dojo/add_tests.html | 32 + dojo/templates_classic/dojo/add_user.html | 35 + dojo/templates_classic/dojo/alerts.html | 71 + dojo/templates_classic/dojo/announcement.html | 16 + dojo/templates_classic/dojo/api_v2_key.html | 42 + .../dojo/apply_finding_template.html | 110 + .../apply_finding_template_form_fields.html | 111 + .../authorize_user_for_product_types.html} | 7 +- .../dojo/authorize_user_for_products.html} | 4 +- .../dojo/banner.html} | 6 +- dojo/templates_classic/dojo/benchmark.html | 414 +++ .../dojo/breadcrumbs/custom_breadcrumb.html | 12 + .../dojo/breadcrumbs/endpoint_breadcrumb.html | 21 + .../breadcrumbs/engagement_breadcrumb.html | 16 + .../dojo/breadcrumbs/finding_breadcrumb.html | 12 + .../dojo/breadcrumbs/settings_breadcrumb.html | 10 + dojo/templates_classic/dojo/calendar.html | 91 + .../templates_classic/dojo/celery_status.html | 305 ++ dojo/templates_classic/dojo/change_pwd.html | 17 + dojo/templates_classic/dojo/checklist.html | 16 + .../dojo/clear_finding_review.html | 25 + .../templates_classic/dojo/close_finding.html | 28 + dojo/templates_classic/dojo/components.html | 180 + dojo/templates_classic/dojo/copy_object.html | 26 + .../dojo/custom_html_report.html | 18 + .../dojo/custom_html_report_cover_page.html | 11 + .../custom_html_report_endpoint_list.html | 200 ++ .../dojo/custom_html_report_finding_list.html | 203 ++ .../custom_html_report_wysiwyg_content.html | 9 + .../dojo/custom_html_toc.html | 85 + .../dojo/dashboard-metrics.html | 539 +++ dojo/templates_classic/dojo/dashboard.html | 378 +++ .../dojo/defect_finding_review.html | 17 + .../templates_classic/dojo/delete_alerts.html | 36 + .../dojo/delete_benchmark.html | 25 + .../dojo/delete_endpoint.html | 28 + .../dojo/delete_engagement.html | 29 + .../dojo/delete_finding_group.html | 29 + .../templates_classic/dojo/delete_github.html | 29 + dojo/templates_classic/dojo/delete_jira.html | 29 + .../dojo/delete_notification_webhook.html | 12 + .../templates_classic/dojo/delete_object.html | 21 + .../dojo/delete_presets.html | 29 + .../dojo/delete_product.html | 26 + ...delete_product_api_scan_configuration.html | 20 + .../dojo/delete_product_type.html | 29 + .../dojo/delete_technology.html} | 4 +- dojo/templates_classic/dojo/delete_test.html | 29 + .../dojo/delete_tool_product.html | 21 + dojo/templates_classic/dojo/delete_user.html | 27 + dojo/templates_classic/dojo/dev_env.html | 86 + .../dojo/disable_note_type.html | 13 + .../dojo/dismiss_announcement.html | 24 + dojo/templates_classic/dojo/edit_dev_env.html | 18 + .../templates_classic/dojo/edit_endpoint.html | 24 + dojo/templates_classic/dojo/edit_finding.html | 235 ++ dojo/templates_classic/dojo/edit_jira.html | 18 + .../templates_classic/dojo/edit_metadata.html | 23 + dojo/templates_classic/dojo/edit_note.html | 14 + .../dojo/edit_note_type.html | 13 + .../dojo/edit_notification_webhook.html | 15 + dojo/templates_classic/dojo/edit_object.html | 18 + dojo/templates_classic/dojo/edit_presets.html | 59 + dojo/templates_classic/dojo/edit_product.html | 72 + .../edit_product_api_scan_configuration.html | 33 + .../dojo/edit_product_type.html | 60 + .../dojo/edit_regulation.html | 22 + .../dojo/edit_sla_config.html | 22 + .../dojo/edit_technology.html | 13 + dojo/templates_classic/dojo/edit_test.html | 25 + .../dojo/edit_test_type.html | 13 + .../dojo/edit_tool_config.html | 18 + .../dojo/edit_tool_product.html | 18 + .../dojo/edit_tool_type.html | 19 + .../dojo/enable_note_type.html | 13 + .../dojo/endpoint_meta_importer.html | 35 + .../dojo/endpoint_pdf_report.html | 425 +++ dojo/templates_classic/dojo/endpoints.html | 277 ++ dojo/templates_classic/dojo/engagement.html | 243 ++ .../dojo/engagement_pdf_report.html | 577 ++++ .../dojo/engagements_all.html | 284 ++ .../dojo/engineer_metrics.html | 75 + .../dojo/filter_js_snippet.html | 60 + .../dojo/filter_snippet.html | 163 + .../dojo/finding_groups_list.html | 8 + .../dojo/finding_groups_list_snippet.html | 243 ++ .../dojo/finding_pdf_report.html | 408 +++ .../dojo/finding_related_actions.html | 38 + .../dojo/finding_related_list.html | 33 + .../dojo/finding_related_row.html | 74 + .../templates_classic/dojo/findings_list.html | 8 + .../dojo/findings_list_snippet.html | 1224 +++++++ dojo/templates_classic/dojo/form_fields.html | 90 + dojo/templates_classic/dojo/github.html | 78 + .../dojo/import_scan_results.html | 75 + dojo/templates_classic/dojo/jira.html | 103 + dojo/templates_classic/dojo/login.html | 46 + dojo/templates_classic/dojo/manage_files.html | 32 + .../templates_classic/dojo/manage_images.html | 29 + .../dojo/merge_findings.html | 60 + dojo/templates_classic/dojo/metrics.html | 965 ++++++ .../dojo/migrate_endpoints.html | 33 + .../dojo/new_dev_env.html} | 4 +- dojo/templates_classic/dojo/new_eng.html | 111 + dojo/templates_classic/dojo/new_github.html | 13 + dojo/templates_classic/dojo/new_jira.html | 16 + .../dojo/new_jira_advanced.html | 13 + dojo/templates_classic/dojo/new_object.html | 18 + dojo/templates_classic/dojo/new_params.html | 58 + dojo/templates_classic/dojo/new_product.html | 69 + .../dojo/new_product_authorized_users.html} | 2 +- .../dojo/new_product_type.html | 60 + .../new_product_type_authorized_users.html} | 2 +- .../dojo/new_regulation.html} | 6 +- .../dojo/new_sla_config.html | 13 + dojo/templates_classic/dojo/new_tech.html | 13 + .../templates_classic/dojo/new_test_type.html | 13 + .../dojo/new_tool_config.html | 13 + .../dojo/new_tool_product.html | 13 + .../templates_classic/dojo/new_tool_type.html | 15 + dojo/templates_classic/dojo/note_type.html | 134 + .../templates_classic/dojo/notifications.html | 168 + .../dojo/paging_snippet.html | 53 + dojo/templates_classic/dojo/product.html | 433 +++ .../dojo/product_components.html | 177 + .../dojo/product_endpoint_pdf_report.html | 553 +++ .../dojo/product_metrics.html | 1300 +++++++ .../dojo/product_pdf_report.html | 570 ++++ dojo/templates_classic/dojo/product_type.html | 157 + .../dojo/product_type_pdf_report.html | 455 +++ dojo/templates_classic/dojo/profile.html | 42 + dojo/templates_classic/dojo/pt_counts.html | 280 ++ dojo/templates_classic/dojo/regulations.html | 94 + .../dojo/regulations_config.html | 70 + .../dojo/remediation_date.html | 20 + .../dojo/report_builder.html | 407 +++ .../dojo/report_cover_page.html | 20 + .../dojo/report_endpoints.html | 64 + .../dojo/report_filter_snippet.html | 34 + .../dojo/report_findings.html | 71 + .../templates_classic/dojo/report_widget.html | 20 + .../dojo/request_endpoint_report.html | 96 + .../dojo/request_report.html | 115 + .../dojo/review_finding.html | 25 + .../dojo/simple_metrics.html | 66 + .../templates_classic/dojo/simple_search.html | 562 ++++ dojo/templates_classic/dojo/sla_config.html | 92 + .../dojo/snippets/comments.html | 85 + .../dojo/snippets/endpoints.html | 280 ++ .../dojo/snippets/engagement_list.html | 364 ++ .../dojo/snippets/file_images.html | 29 + .../risk_acceptance_actions_snippet.html | 52 + .../risk_acceptance_actions_snippet_js.html | 22 + .../snippets/selectpicker_in_dropdown.html | 0 .../dojo/snippets/sonarqube_history.html | 29 + .../templates_classic/dojo/snippets/tags.html | 13 + dojo/templates_classic/dojo/support.html | 82 + .../dojo/system_settings.html | 92 + dojo/templates_classic/dojo/templates.html | 194 ++ .../dojo/test_pdf_report.html | 589 ++++ dojo/templates_classic/dojo/test_type.html | 85 + dojo/templates_classic/dojo/tool_config.html | 85 + dojo/templates_classic/dojo/tool_type.html | 78 + dojo/templates_classic/dojo/up_threat.html | 13 + dojo/templates_classic/dojo/url/create.html | 24 + dojo/templates_classic/dojo/url/delete.html | 38 + dojo/templates_classic/dojo/url/list.html | 276 ++ dojo/templates_classic/dojo/url/update.html | 24 + dojo/templates_classic/dojo/url/view.html | 410 +++ dojo/templates_classic/dojo/users.html | 158 + .../dojo/verify_finding.html | 18 + .../templates_classic/dojo/view_endpoint.html | 418 +++ dojo/templates_classic/dojo/view_eng.html | 1019 ++++++ .../dojo/view_engagements.html | 61 + .../templates_classic/dojo/view_engineer.html | 655 ++++ dojo/templates_classic/dojo/view_finding.html | 1573 +++++++++ .../dojo/view_finding_group.html | 30 + .../dojo/view_note_history.html | 35 + .../dojo/view_notification_webhooks.html | 101 + dojo/templates_classic/dojo/view_objects.html | 146 + .../dojo/view_objects_eng.html | 130 + dojo/templates_classic/dojo/view_presets.html | 69 + .../view_product_api_scan_configurations.html | 68 + .../dojo/view_product_details.html | 645 ++++ .../dojo/view_product_type.html | 249 ++ .../dojo/view_risk_acceptance.html | 439 +++ dojo/templates_classic/dojo/view_test.html | 1867 ++++++++++ .../dojo/view_tool_product_all.html | 69 + dojo/templates_classic/dojo/view_user.html | 393 +++ .../templates_classic/google_sheet_error.html | 9 + .../jira_full/jira-description.tpl | 104 + .../jira-finding-group-description.tpl | 104 + .../jira_limited/jira-description.tpl | 17 + .../jira-finding-group-description.tpl | 39 + .../login/forgot_password.tpl | 12 + .../login/forgot_username.html | 17 + .../login/forgot_username.tpl | 10 + .../login/forgot_username_done.html | 10 + .../login/forgot_username_subject.html | 3 + .../login/password_reset.html | 18 + .../login/password_reset_complete.html | 14 + .../login/password_reset_confirm.html | 23 + .../login/password_reset_done.html | 10 + .../notifications/alert/engagement_added.tpl | 3 + .../notifications/alert/engagement_closed.tpl | 3 + .../notifications/alert/other.tpl | 1 + .../notifications/alert/product_added.tpl | 3 + .../alert/product_type_added.tpl | 3 + .../notifications/alert/review_requested.tpl | 20 + .../notifications/alert/scan_added_empty.tpl | 1 + .../notifications/alert/sla_breach.tpl | 3 + .../notifications/alert/test_added.tpl | 3 + .../alert/upcoming_engagement.tpl | 3 + .../notifications/alert/user_mentioned.tpl | 4 + .../notifications/mail/engagement_added.tpl | 41 + .../notifications/mail/engagement_closed.tpl | 41 + .../notifications/mail/other.tpl | 43 + .../notifications/mail/product_added.tpl | 40 + .../notifications/mail/product_type_added.tpl | 57 + .../notifications/mail/review_requested.tpl | 45 + .../mail/risk_acceptance_expiration.tpl | 64 + .../notifications/mail/scan_added.tpl | 84 + .../notifications/mail/scan_added_empty.tpl | 1 + .../notifications/mail/sla_breach.tpl | 57 + .../mail/sla_breach_combined.tpl | 72 + .../notifications/mail/test_added.tpl | 42 + .../mail/upcoming_engagement.tpl | 40 + .../notifications/mail/user_mentioned.tpl | 43 + .../msteams/engagement_added.tpl | 103 + .../msteams/engagement_closed.tpl | 103 + .../notifications/msteams/other.tpl | 81 + .../notifications/msteams/product_added.tpl | 107 + .../msteams/product_type_added.tpl | 99 + .../msteams/review_requested.tpl | 124 + .../msteams/risk_acceptance_expiration.tpl | 107 + .../notifications/msteams/scan_added.tpl | 123 + .../msteams/scan_added_empty.tpl | 1 + .../notifications/msteams/sla_breach.tpl | 107 + .../notifications/msteams/test_added.tpl | 103 + .../msteams/upcoming_engagement.tpl | 103 + .../notifications/msteams/user_mentioned.tpl | 107 + .../notifications/slack/engagement_added.tpl | 10 + .../notifications/slack/engagement_closed.tpl | 10 + .../notifications/slack/other.tpl | 13 + .../notifications/slack/product_added.tpl | 10 + .../slack/product_type_added.tpl | 10 + .../notifications/slack/report_created.tpl | 10 + .../notifications/slack/review_requested.tpl | 21 + .../slack/risk_acceptance_expiration.tpl | 18 + .../notifications/slack/scan_added.tpl | 15 + .../notifications/slack/scan_added_empty.tpl | 1 + .../notifications/slack/sla_breach.tpl | 13 + .../notifications/slack/test_added.tpl | 13 + .../slack/upcoming_engagement.tpl | 8 + .../notifications/slack/user_mentioned.tpl | 12 + .../webhooks/engagement_added.tpl | 2 + .../notifications/webhooks/other.tpl | 1 + .../notifications/webhooks/product_added.tpl | 2 + .../webhooks/product_type_added.tpl | 2 + .../notifications/webhooks/scan_added.tpl | 12 + .../webhooks/scan_added_empty.tpl | 1 + .../webhooks/subtemplates/base.tpl | 15 + .../webhooks/subtemplates/engagement.tpl | 14 + .../webhooks/subtemplates/findings_list.tpl | 13 + .../webhooks/subtemplates/product.tpl | 14 + .../webhooks/subtemplates/product_type.tpl | 9 + .../webhooks/subtemplates/test.tpl | 14 + .../webhooks/subtemplates/user.tpl | 16 + .../notifications/webhooks/test_added.tpl | 2 + dojo/templates_classic/pt_nav_items.html | 3 + dojo/templates_classic/report_base.html | 226 ++ dojo/templatetags/authorization_tags.py | 63 +- dojo/templatetags/display_tags.py | 18 + dojo/templatetags/filter_tags.py | 115 + dojo/templatetags/navigation_tags.py | 3 +- dojo/test/queries.py | 95 +- dojo/test/views.py | 34 +- dojo/test_type/views.py | 3 - dojo/tool_config/views.py | 4 - dojo/tool_product/queries.py | 50 +- dojo/tool_product/views.py | 6 - dojo/tool_type/views.py | 4 - dojo/url/api/views.py | 2 +- dojo/url/filters.py | 3 +- dojo/url/ui/views.py | 29 +- dojo/urls.py | 32 +- dojo/user/queries.py | 146 +- dojo/user/urls.py | 13 +- dojo/user/views.py | 279 +- dojo/utils.py | 19 +- dojo/views.py | 21 +- requirements.txt | 4 +- tests/base_test_class.py | 4 +- tests/group_test.py | 204 -- tests/login_test.py | 10 +- tests/product_group_test.py | 183 - tests/product_member_test.py | 222 +- tests/product_type_group_test.py | 180 - tests/product_type_member_test.py | 228 +- tests/user_test.py | 7 +- unittests/authorization/test_authorization.py | 851 ++--- .../authorization/test_authorization_tags.py | 27 +- unittests/test_apiv2_user.py | 13 +- unittests/test_apply_finding_template.py | 59 +- unittests/test_authorization_queries.py | 142 +- unittests/test_authorized_users_ui.py | 196 ++ unittests/test_bulk_edit_validation.py | 6 +- unittests/test_bulk_risk_acceptance_api.py | 33 +- unittests/test_dashboard.py | 20 + unittests/test_importers_performance.py | 13 +- unittests/test_metrics_queries.py | 22 +- unittests/test_notifications.py | 18 +- unittests/test_permissions_audit.py | 133 +- unittests/test_remote_user.py | 184 - unittests/test_rest_framework.py | 484 +-- unittests/test_risk_acceptance_api.py | 6 +- .../test_social_auth_failure_handling.py | 153 - unittests/test_tag_inheritance_perf.py | 10 +- unittests/test_update_import_history.py | 36 +- unittests/test_user_queries.py | 156 +- unittests/test_user_ui_timestamps.py | 38 + unittests/test_utils.py | 65 +- 689 files changed, 48989 insertions(+), 18293 deletions(-) delete mode 100644 components/node_modules/.gitkeep create mode 100644 components/tailwind.css delete mode 100644 docs/content/admin/sso/OS__auth0.md delete mode 100644 docs/content/admin/sso/OS__azure_ad.md delete mode 100644 docs/content/admin/sso/OS__github_enterprise.md delete mode 100644 docs/content/admin/sso/OS__gitlab.md delete mode 100644 docs/content/admin/sso/OS__google.md delete mode 100644 docs/content/admin/sso/OS__keycloak.md delete mode 100644 docs/content/admin/sso/OS__oidc.md delete mode 100644 docs/content/admin/sso/OS__okta.md delete mode 100644 docs/content/admin/sso/OS__remote_user.md delete mode 100644 docs/content/admin/sso/OS__saml.md create mode 100644 docs/content/admin/user_management/OS__authorized_users.md delete mode 100644 docs/content/admin/user_management/configure_sso.md create mode 100644 dojo/authorization/MIGRATION_REHEARSAL.md create mode 100644 dojo/authorization/api_permissions.py create mode 100644 dojo/authorization/middleware.py create mode 100644 dojo/authorization/models.py create mode 100644 dojo/authorization/query_filters.py create mode 100644 dojo/authorization/query_registrations.py create mode 100644 dojo/authorization/template_filters.py create mode 100644 dojo/authorization/url_permissions.py create mode 100644 dojo/db_migrations/0267_usercontactinfo_ui_use_tailwind.py create mode 100644 dojo/db_migrations/0268_release_authorization_to_pro.py delete mode 100644 dojo/group/__init__.py delete mode 100644 dojo/group/queries.py delete mode 100644 dojo/group/urls.py delete mode 100644 dojo/group/utils.py delete mode 100644 dojo/group/views.py delete mode 100644 dojo/location/api/permissions.py delete mode 100644 dojo/management/commands/migrate_staff_users.py delete mode 100644 dojo/sso/__init__.py delete mode 100644 dojo/sso/attribute_maps/__init__.py delete mode 100644 dojo/sso/attribute_maps/django_saml_uri.py delete mode 100644 dojo/sso/attribute_maps/saml_uri.py delete mode 100644 dojo/sso/context_processors.py delete mode 100644 dojo/sso/middleware.py delete mode 100644 dojo/sso/pipeline.py delete mode 100644 dojo/sso/remote_user.py delete mode 100644 dojo/sso/settings.py delete mode 100644 dojo/sso/templates/dojo/sso_login_buttons.html delete mode 100644 dojo/sso/urls.py delete mode 100644 dojo/sso/views.py create mode 100644 dojo/static/dojo/css/classic/dojo.css create mode 100644 dojo/static/dojo/css/datatables-dd.css create mode 100644 dojo/static/dojo/css/files/work-sans-latin-ext-wght-normal.woff2 create mode 100644 dojo/static/dojo/css/files/work-sans-latin-wght-normal.woff2 create mode 100644 dojo/static/dojo/css/files/work-sans-vietnamese-wght-normal.woff2 create mode 100644 dojo/static/dojo/css/tailwind-out.css create mode 100644 dojo/static/dojo/js/classic/index.js create mode 100644 dojo/static/dojo/js/classic/metrics.js create mode 100644 dojo/static/dojo/js/vendor/alpine.min.js create mode 100644 dojo/static/dojo/js/vendor/htmx.min.js create mode 100644 dojo/template_loaders.py delete mode 100644 dojo/templates/dojo/add_group.html create mode 100644 dojo/templates/dojo/authorize_user_for_product_types.html create mode 100644 dojo/templates/dojo/authorize_user_for_products.html delete mode 100644 dojo/templates/dojo/delete_group.html delete mode 100644 dojo/templates/dojo/delete_product_group.html delete mode 100644 dojo/templates/dojo/delete_product_type_group.html delete mode 100644 dojo/templates/dojo/edit_group_member.html delete mode 100644 dojo/templates/dojo/edit_product_group.html delete mode 100644 dojo/templates/dojo/edit_product_type_group.html delete mode 100644 dojo/templates/dojo/groups.html delete mode 100644 dojo/templates/dojo/new_group_member.html delete mode 100644 dojo/templates/dojo/new_group_member_user.html rename dojo/templates/dojo/{edit_product_type_member.html => new_product_authorized_users.html} (77%) delete mode 100644 dojo/templates/dojo/new_product_group.html delete mode 100644 dojo/templates/dojo/new_product_group_group.html create mode 100644 dojo/templates/dojo/new_product_type_authorized_users.html delete mode 100644 dojo/templates/dojo/new_product_type_group.html delete mode 100644 dojo/templates/dojo/new_product_type_group_group.html create mode 100644 dojo/templates/dojo/partials/alerts_dropdown.html create mode 100644 dojo/templates/dojo/snippets/empty_state.html delete mode 100644 dojo/templates/dojo/view_group.html create mode 100644 dojo/templates_classic/400.html create mode 100644 dojo/templates_classic/403.html create mode 100644 dojo/templates_classic/404.html create mode 100644 dojo/templates_classic/500.html create mode 100644 dojo/templates_classic/base.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_choices.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_engagement.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_survey.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_surveys.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/answer_survey.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/assign_survey.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/create_questionnaire.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/create_related_question.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/delete_questionnaire.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/edit_question.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/edit_survey_questions.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/existing_engagement.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/list_questions.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/list_surveys.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/survey_fields.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/surveys.html create mode 100644 dojo/templates_classic/defectDojo-engagement-survey/view_survey.html create mode 100644 dojo/templates_classic/disabled.html create mode 100644 dojo/templates_classic/dojo/action_history.html create mode 100644 dojo/templates_classic/dojo/ad_hoc_findings.html create mode 100644 dojo/templates_classic/dojo/add_endpoint.html create mode 100644 dojo/templates_classic/dojo/add_findings.html create mode 100644 dojo/templates_classic/dojo/add_findings_as_accepted.html create mode 100644 dojo/templates_classic/dojo/add_note_type.html create mode 100644 dojo/templates_classic/dojo/add_notification_webhook.html create mode 100644 dojo/templates_classic/dojo/add_product_api_scan_configuration.html create mode 100644 dojo/templates_classic/dojo/add_related.html create mode 100644 dojo/templates_classic/dojo/add_risk_acceptance.html create mode 100644 dojo/templates_classic/dojo/add_template.html create mode 100644 dojo/templates_classic/dojo/add_tests.html create mode 100644 dojo/templates_classic/dojo/add_user.html create mode 100644 dojo/templates_classic/dojo/alerts.html create mode 100644 dojo/templates_classic/dojo/announcement.html create mode 100644 dojo/templates_classic/dojo/api_v2_key.html create mode 100755 dojo/templates_classic/dojo/apply_finding_template.html create mode 100755 dojo/templates_classic/dojo/apply_finding_template_form_fields.html rename dojo/{templates/dojo/new_product_member.html => templates_classic/dojo/authorize_user_for_product_types.html} (65%) rename dojo/{templates/dojo/new_product_member_user.html => templates_classic/dojo/authorize_user_for_products.html} (71%) rename dojo/{templates/dojo/delete_product_type_member.html => templates_classic/dojo/banner.html} (51%) create mode 100644 dojo/templates_classic/dojo/benchmark.html create mode 100644 dojo/templates_classic/dojo/breadcrumbs/custom_breadcrumb.html create mode 100644 dojo/templates_classic/dojo/breadcrumbs/endpoint_breadcrumb.html create mode 100644 dojo/templates_classic/dojo/breadcrumbs/engagement_breadcrumb.html create mode 100644 dojo/templates_classic/dojo/breadcrumbs/finding_breadcrumb.html create mode 100644 dojo/templates_classic/dojo/breadcrumbs/settings_breadcrumb.html create mode 100644 dojo/templates_classic/dojo/calendar.html create mode 100644 dojo/templates_classic/dojo/celery_status.html create mode 100644 dojo/templates_classic/dojo/change_pwd.html create mode 100644 dojo/templates_classic/dojo/checklist.html create mode 100644 dojo/templates_classic/dojo/clear_finding_review.html create mode 100644 dojo/templates_classic/dojo/close_finding.html create mode 100644 dojo/templates_classic/dojo/components.html create mode 100644 dojo/templates_classic/dojo/copy_object.html create mode 100644 dojo/templates_classic/dojo/custom_html_report.html create mode 100644 dojo/templates_classic/dojo/custom_html_report_cover_page.html create mode 100644 dojo/templates_classic/dojo/custom_html_report_endpoint_list.html create mode 100644 dojo/templates_classic/dojo/custom_html_report_finding_list.html create mode 100644 dojo/templates_classic/dojo/custom_html_report_wysiwyg_content.html create mode 100644 dojo/templates_classic/dojo/custom_html_toc.html create mode 100644 dojo/templates_classic/dojo/dashboard-metrics.html create mode 100644 dojo/templates_classic/dojo/dashboard.html create mode 100644 dojo/templates_classic/dojo/defect_finding_review.html create mode 100644 dojo/templates_classic/dojo/delete_alerts.html create mode 100644 dojo/templates_classic/dojo/delete_benchmark.html create mode 100644 dojo/templates_classic/dojo/delete_endpoint.html create mode 100644 dojo/templates_classic/dojo/delete_engagement.html create mode 100644 dojo/templates_classic/dojo/delete_finding_group.html create mode 100644 dojo/templates_classic/dojo/delete_github.html create mode 100644 dojo/templates_classic/dojo/delete_jira.html create mode 100644 dojo/templates_classic/dojo/delete_notification_webhook.html create mode 100644 dojo/templates_classic/dojo/delete_object.html create mode 100644 dojo/templates_classic/dojo/delete_presets.html create mode 100644 dojo/templates_classic/dojo/delete_product.html create mode 100644 dojo/templates_classic/dojo/delete_product_api_scan_configuration.html create mode 100644 dojo/templates_classic/dojo/delete_product_type.html rename dojo/{templates/dojo/delete_group_member.html => templates_classic/dojo/delete_technology.html} (66%) create mode 100644 dojo/templates_classic/dojo/delete_test.html create mode 100644 dojo/templates_classic/dojo/delete_tool_product.html create mode 100644 dojo/templates_classic/dojo/delete_user.html create mode 100644 dojo/templates_classic/dojo/dev_env.html create mode 100644 dojo/templates_classic/dojo/disable_note_type.html create mode 100644 dojo/templates_classic/dojo/dismiss_announcement.html create mode 100644 dojo/templates_classic/dojo/edit_dev_env.html create mode 100644 dojo/templates_classic/dojo/edit_endpoint.html create mode 100644 dojo/templates_classic/dojo/edit_finding.html create mode 100644 dojo/templates_classic/dojo/edit_jira.html create mode 100644 dojo/templates_classic/dojo/edit_metadata.html create mode 100644 dojo/templates_classic/dojo/edit_note.html create mode 100644 dojo/templates_classic/dojo/edit_note_type.html create mode 100644 dojo/templates_classic/dojo/edit_notification_webhook.html create mode 100644 dojo/templates_classic/dojo/edit_object.html create mode 100644 dojo/templates_classic/dojo/edit_presets.html create mode 100644 dojo/templates_classic/dojo/edit_product.html create mode 100644 dojo/templates_classic/dojo/edit_product_api_scan_configuration.html create mode 100644 dojo/templates_classic/dojo/edit_product_type.html create mode 100644 dojo/templates_classic/dojo/edit_regulation.html create mode 100644 dojo/templates_classic/dojo/edit_sla_config.html create mode 100644 dojo/templates_classic/dojo/edit_technology.html create mode 100644 dojo/templates_classic/dojo/edit_test.html create mode 100644 dojo/templates_classic/dojo/edit_test_type.html create mode 100644 dojo/templates_classic/dojo/edit_tool_config.html create mode 100644 dojo/templates_classic/dojo/edit_tool_product.html create mode 100644 dojo/templates_classic/dojo/edit_tool_type.html create mode 100644 dojo/templates_classic/dojo/enable_note_type.html create mode 100644 dojo/templates_classic/dojo/endpoint_meta_importer.html create mode 100644 dojo/templates_classic/dojo/endpoint_pdf_report.html create mode 100644 dojo/templates_classic/dojo/endpoints.html create mode 100644 dojo/templates_classic/dojo/engagement.html create mode 100644 dojo/templates_classic/dojo/engagement_pdf_report.html create mode 100644 dojo/templates_classic/dojo/engagements_all.html create mode 100644 dojo/templates_classic/dojo/engineer_metrics.html create mode 100644 dojo/templates_classic/dojo/filter_js_snippet.html create mode 100644 dojo/templates_classic/dojo/filter_snippet.html create mode 100644 dojo/templates_classic/dojo/finding_groups_list.html create mode 100644 dojo/templates_classic/dojo/finding_groups_list_snippet.html create mode 100644 dojo/templates_classic/dojo/finding_pdf_report.html create mode 100644 dojo/templates_classic/dojo/finding_related_actions.html create mode 100644 dojo/templates_classic/dojo/finding_related_list.html create mode 100644 dojo/templates_classic/dojo/finding_related_row.html create mode 100644 dojo/templates_classic/dojo/findings_list.html create mode 100644 dojo/templates_classic/dojo/findings_list_snippet.html create mode 100644 dojo/templates_classic/dojo/form_fields.html create mode 100644 dojo/templates_classic/dojo/github.html create mode 100755 dojo/templates_classic/dojo/import_scan_results.html create mode 100644 dojo/templates_classic/dojo/jira.html create mode 100644 dojo/templates_classic/dojo/login.html create mode 100644 dojo/templates_classic/dojo/manage_files.html create mode 100644 dojo/templates_classic/dojo/manage_images.html create mode 100644 dojo/templates_classic/dojo/merge_findings.html create mode 100644 dojo/templates_classic/dojo/metrics.html create mode 100644 dojo/templates_classic/dojo/migrate_endpoints.html rename dojo/{templates/dojo/edit_product_member.html => templates_classic/dojo/new_dev_env.html} (71%) create mode 100644 dojo/templates_classic/dojo/new_eng.html create mode 100644 dojo/templates_classic/dojo/new_github.html create mode 100644 dojo/templates_classic/dojo/new_jira.html create mode 100644 dojo/templates_classic/dojo/new_jira_advanced.html create mode 100644 dojo/templates_classic/dojo/new_object.html create mode 100644 dojo/templates_classic/dojo/new_params.html create mode 100644 dojo/templates_classic/dojo/new_product.html rename dojo/{templates/dojo/new_product_type_member_user.html => templates_classic/dojo/new_product_authorized_users.html} (80%) create mode 100644 dojo/templates_classic/dojo/new_product_type.html rename dojo/{templates/dojo/new_product_type_member.html => templates_classic/dojo/new_product_type_authorized_users.html} (90%) rename dojo/{templates/dojo/delete_product_member.html => templates_classic/dojo/new_regulation.html} (55%) create mode 100644 dojo/templates_classic/dojo/new_sla_config.html create mode 100644 dojo/templates_classic/dojo/new_tech.html create mode 100644 dojo/templates_classic/dojo/new_test_type.html create mode 100644 dojo/templates_classic/dojo/new_tool_config.html create mode 100644 dojo/templates_classic/dojo/new_tool_product.html create mode 100644 dojo/templates_classic/dojo/new_tool_type.html create mode 100644 dojo/templates_classic/dojo/note_type.html create mode 100644 dojo/templates_classic/dojo/notifications.html create mode 100644 dojo/templates_classic/dojo/paging_snippet.html create mode 100644 dojo/templates_classic/dojo/product.html create mode 100644 dojo/templates_classic/dojo/product_components.html create mode 100644 dojo/templates_classic/dojo/product_endpoint_pdf_report.html create mode 100644 dojo/templates_classic/dojo/product_metrics.html create mode 100644 dojo/templates_classic/dojo/product_pdf_report.html create mode 100644 dojo/templates_classic/dojo/product_type.html create mode 100644 dojo/templates_classic/dojo/product_type_pdf_report.html create mode 100644 dojo/templates_classic/dojo/profile.html create mode 100644 dojo/templates_classic/dojo/pt_counts.html create mode 100644 dojo/templates_classic/dojo/regulations.html create mode 100644 dojo/templates_classic/dojo/regulations_config.html create mode 100644 dojo/templates_classic/dojo/remediation_date.html create mode 100644 dojo/templates_classic/dojo/report_builder.html create mode 100644 dojo/templates_classic/dojo/report_cover_page.html create mode 100644 dojo/templates_classic/dojo/report_endpoints.html create mode 100644 dojo/templates_classic/dojo/report_filter_snippet.html create mode 100644 dojo/templates_classic/dojo/report_findings.html create mode 100644 dojo/templates_classic/dojo/report_widget.html create mode 100644 dojo/templates_classic/dojo/request_endpoint_report.html create mode 100644 dojo/templates_classic/dojo/request_report.html create mode 100644 dojo/templates_classic/dojo/review_finding.html create mode 100644 dojo/templates_classic/dojo/simple_metrics.html create mode 100644 dojo/templates_classic/dojo/simple_search.html create mode 100644 dojo/templates_classic/dojo/sla_config.html create mode 100644 dojo/templates_classic/dojo/snippets/comments.html create mode 100644 dojo/templates_classic/dojo/snippets/endpoints.html create mode 100644 dojo/templates_classic/dojo/snippets/engagement_list.html create mode 100644 dojo/templates_classic/dojo/snippets/file_images.html create mode 100644 dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet.html create mode 100644 dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet_js.html rename dojo/{templates => templates_classic}/dojo/snippets/selectpicker_in_dropdown.html (100%) create mode 100644 dojo/templates_classic/dojo/snippets/sonarqube_history.html create mode 100644 dojo/templates_classic/dojo/snippets/tags.html create mode 100644 dojo/templates_classic/dojo/support.html create mode 100644 dojo/templates_classic/dojo/system_settings.html create mode 100644 dojo/templates_classic/dojo/templates.html create mode 100644 dojo/templates_classic/dojo/test_pdf_report.html create mode 100644 dojo/templates_classic/dojo/test_type.html create mode 100644 dojo/templates_classic/dojo/tool_config.html create mode 100644 dojo/templates_classic/dojo/tool_type.html create mode 100644 dojo/templates_classic/dojo/up_threat.html create mode 100644 dojo/templates_classic/dojo/url/create.html create mode 100644 dojo/templates_classic/dojo/url/delete.html create mode 100644 dojo/templates_classic/dojo/url/list.html create mode 100644 dojo/templates_classic/dojo/url/update.html create mode 100644 dojo/templates_classic/dojo/url/view.html create mode 100644 dojo/templates_classic/dojo/users.html create mode 100644 dojo/templates_classic/dojo/verify_finding.html create mode 100644 dojo/templates_classic/dojo/view_endpoint.html create mode 100644 dojo/templates_classic/dojo/view_eng.html create mode 100644 dojo/templates_classic/dojo/view_engagements.html create mode 100644 dojo/templates_classic/dojo/view_engineer.html create mode 100755 dojo/templates_classic/dojo/view_finding.html create mode 100644 dojo/templates_classic/dojo/view_finding_group.html create mode 100644 dojo/templates_classic/dojo/view_note_history.html create mode 100644 dojo/templates_classic/dojo/view_notification_webhooks.html create mode 100644 dojo/templates_classic/dojo/view_objects.html create mode 100644 dojo/templates_classic/dojo/view_objects_eng.html create mode 100644 dojo/templates_classic/dojo/view_presets.html create mode 100644 dojo/templates_classic/dojo/view_product_api_scan_configurations.html create mode 100644 dojo/templates_classic/dojo/view_product_details.html create mode 100644 dojo/templates_classic/dojo/view_product_type.html create mode 100644 dojo/templates_classic/dojo/view_risk_acceptance.html create mode 100644 dojo/templates_classic/dojo/view_test.html create mode 100644 dojo/templates_classic/dojo/view_tool_product_all.html create mode 100644 dojo/templates_classic/dojo/view_user.html create mode 100644 dojo/templates_classic/google_sheet_error.html create mode 100644 dojo/templates_classic/issue-trackers/jira_full/jira-description.tpl create mode 100644 dojo/templates_classic/issue-trackers/jira_full/jira-finding-group-description.tpl create mode 100644 dojo/templates_classic/issue-trackers/jira_limited/jira-description.tpl create mode 100644 dojo/templates_classic/issue-trackers/jira_limited/jira-finding-group-description.tpl create mode 100644 dojo/templates_classic/login/forgot_password.tpl create mode 100644 dojo/templates_classic/login/forgot_username.html create mode 100644 dojo/templates_classic/login/forgot_username.tpl create mode 100644 dojo/templates_classic/login/forgot_username_done.html create mode 100644 dojo/templates_classic/login/forgot_username_subject.html create mode 100644 dojo/templates_classic/login/password_reset.html create mode 100644 dojo/templates_classic/login/password_reset_complete.html create mode 100644 dojo/templates_classic/login/password_reset_confirm.html create mode 100644 dojo/templates_classic/login/password_reset_done.html create mode 100644 dojo/templates_classic/notifications/alert/engagement_added.tpl create mode 100644 dojo/templates_classic/notifications/alert/engagement_closed.tpl create mode 100644 dojo/templates_classic/notifications/alert/other.tpl create mode 100644 dojo/templates_classic/notifications/alert/product_added.tpl create mode 100644 dojo/templates_classic/notifications/alert/product_type_added.tpl create mode 100644 dojo/templates_classic/notifications/alert/review_requested.tpl create mode 100644 dojo/templates_classic/notifications/alert/scan_added_empty.tpl create mode 100644 dojo/templates_classic/notifications/alert/sla_breach.tpl create mode 100644 dojo/templates_classic/notifications/alert/test_added.tpl create mode 100644 dojo/templates_classic/notifications/alert/upcoming_engagement.tpl create mode 100644 dojo/templates_classic/notifications/alert/user_mentioned.tpl create mode 100644 dojo/templates_classic/notifications/mail/engagement_added.tpl create mode 100644 dojo/templates_classic/notifications/mail/engagement_closed.tpl create mode 100644 dojo/templates_classic/notifications/mail/other.tpl create mode 100644 dojo/templates_classic/notifications/mail/product_added.tpl create mode 100644 dojo/templates_classic/notifications/mail/product_type_added.tpl create mode 100644 dojo/templates_classic/notifications/mail/review_requested.tpl create mode 100644 dojo/templates_classic/notifications/mail/risk_acceptance_expiration.tpl create mode 100644 dojo/templates_classic/notifications/mail/scan_added.tpl create mode 120000 dojo/templates_classic/notifications/mail/scan_added_empty.tpl create mode 100644 dojo/templates_classic/notifications/mail/sla_breach.tpl create mode 100644 dojo/templates_classic/notifications/mail/sla_breach_combined.tpl create mode 100644 dojo/templates_classic/notifications/mail/test_added.tpl create mode 100644 dojo/templates_classic/notifications/mail/upcoming_engagement.tpl create mode 100644 dojo/templates_classic/notifications/mail/user_mentioned.tpl create mode 100644 dojo/templates_classic/notifications/msteams/engagement_added.tpl create mode 100644 dojo/templates_classic/notifications/msteams/engagement_closed.tpl create mode 100644 dojo/templates_classic/notifications/msteams/other.tpl create mode 100644 dojo/templates_classic/notifications/msteams/product_added.tpl create mode 100644 dojo/templates_classic/notifications/msteams/product_type_added.tpl create mode 100644 dojo/templates_classic/notifications/msteams/review_requested.tpl create mode 100644 dojo/templates_classic/notifications/msteams/risk_acceptance_expiration.tpl create mode 100644 dojo/templates_classic/notifications/msteams/scan_added.tpl create mode 120000 dojo/templates_classic/notifications/msteams/scan_added_empty.tpl create mode 100644 dojo/templates_classic/notifications/msteams/sla_breach.tpl create mode 100644 dojo/templates_classic/notifications/msteams/test_added.tpl create mode 100644 dojo/templates_classic/notifications/msteams/upcoming_engagement.tpl create mode 100644 dojo/templates_classic/notifications/msteams/user_mentioned.tpl create mode 100644 dojo/templates_classic/notifications/slack/engagement_added.tpl create mode 100644 dojo/templates_classic/notifications/slack/engagement_closed.tpl create mode 100644 dojo/templates_classic/notifications/slack/other.tpl create mode 100644 dojo/templates_classic/notifications/slack/product_added.tpl create mode 100644 dojo/templates_classic/notifications/slack/product_type_added.tpl create mode 100644 dojo/templates_classic/notifications/slack/report_created.tpl create mode 100644 dojo/templates_classic/notifications/slack/review_requested.tpl create mode 100644 dojo/templates_classic/notifications/slack/risk_acceptance_expiration.tpl create mode 100644 dojo/templates_classic/notifications/slack/scan_added.tpl create mode 120000 dojo/templates_classic/notifications/slack/scan_added_empty.tpl create mode 100644 dojo/templates_classic/notifications/slack/sla_breach.tpl create mode 100644 dojo/templates_classic/notifications/slack/test_added.tpl create mode 100644 dojo/templates_classic/notifications/slack/upcoming_engagement.tpl create mode 100644 dojo/templates_classic/notifications/slack/user_mentioned.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/engagement_added.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/other.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/product_added.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/product_type_added.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/scan_added.tpl create mode 120000 dojo/templates_classic/notifications/webhooks/scan_added_empty.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/base.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/engagement.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/findings_list.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/product.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/product_type.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/test.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/user.tpl create mode 100644 dojo/templates_classic/notifications/webhooks/test_added.tpl create mode 100644 dojo/templates_classic/pt_nav_items.html create mode 100644 dojo/templates_classic/report_base.html create mode 100644 dojo/templatetags/filter_tags.py delete mode 100644 tests/group_test.py delete mode 100644 tests/product_group_test.py delete mode 100644 tests/product_type_group_test.py create mode 100644 unittests/test_authorized_users_ui.py delete mode 100644 unittests/test_remote_user.py delete mode 100644 unittests/test_social_auth_failure_handling.py diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index f0d466264d0..51fcd366e32 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -37,7 +37,6 @@ jobs: "tests/finding_extended_test.py", "tests/finding_group_test.py", "tests/finding_test.py", - "tests/group_test.py", "tests/login_test.py", "tests/metrics_extended_test.py", "tests/note_type_test.py", @@ -45,12 +44,10 @@ jobs: "tests/notification_webhook_test.py", "tests/notifications_test.py", "tests/object_test.py", - "tests/product_group_test.py", "tests/product_member_test.py", "tests/product_metadata_test.py", "tests/product_tag_metrics_test.py", "tests/product_test.py", - "tests/product_type_group_test.py", "tests/product_type_member_test.py", "tests/product_type_test.py", "tests/questionnaire_advanced_test.py", diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine index 6df6efa8f82..81d6b8d7d6f 100644 --- a/Dockerfile.nginx-alpine +++ b/Dockerfile.nginx-alpine @@ -61,6 +61,8 @@ RUN \ yarn COPY manage.py ./ COPY dojo/ ./dojo/ +# Build Tailwind CSS +RUN cd components && yarn build:css # always collect static for debug toolbar as we can't make it dependant on env variables or build arguments without breaking docker layer caching RUN env DD_SECRET_KEY='.' DD_DJANGO_DEBUG_TOOLBAR_ENABLED=True python3 manage.py collectstatic --noinput --verbosity=2 && true diff --git a/components/node_modules/.gitkeep b/components/node_modules/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/components/package.json b/components/package.json index 57d14de8e23..4eeddd2035f 100644 --- a/components/package.json +++ b/components/package.json @@ -1,14 +1,18 @@ { "name": "defectdojo", "version": "2.59.0-dev", - "license" : "BSD-3-Clause", + "license": "BSD-3-Clause", "private": true, "dependencies": { + "@fontsource-variable/work-sans": "^5.1", "JUMFlot": "jumjum123/JUMFlot#*", + "alpinejs": "^3.14", "bootstrap": "^3.4.1", "bootstrap-select": "^1.13.18", "bootstrap-social": "^4.0.0", "bootstrap-wysiwyg": "^2.0.0", + "chart.js": "^4.4", + "chartjs-adapter-moment": "^1.0", "chosen-bootstrap": "https://github.com/dbtek/chosen-bootstrap", "chosen-js": "^1.8.7", "clipboard": "^2.0.11", @@ -18,10 +22,12 @@ "drmonty-datatables-plugins": "^1.0.0", "drmonty-datatables-responsive": "^1.0.0", "easymde": "^2.21.0", + "flatpickr": "^4.6", "flot": "flot/flot#~0.8.3", "font-awesome": "^4.0.0", "fullcalendar": "^3.10.2", "google-code-prettify": "^1.0.0", + "htmx.org": "^2.0", "jquery": "^3.7.1", "jquery-highlight": "3.5.0", "jquery-ui": "1.14.2", @@ -36,6 +42,16 @@ "pdfmake": "^0.3.7", "startbootstrap-sb-admin-2": "1.0.7" }, + "devDependencies": { + "@tailwindcss/cli": "^4.1", + "@tailwindcss/forms": "^0.5", + "tailwindcss": "^4.1" + }, + "scripts": { + "copy:fonts": "mkdir -p ../dojo/static/dojo/css/files && cp node_modules/@fontsource-variable/work-sans/files/work-sans-*-wght-normal.woff2 ../dojo/static/dojo/css/files/", + "build:css": "npm run copy:fonts && npx @tailwindcss/cli -i tailwind.css -o ../dojo/static/dojo/css/tailwind-out.css --minify", + "watch:css": "npm run copy:fonts && npx @tailwindcss/cli -i tailwind.css -o ../dojo/static/dojo/css/tailwind-out.css --watch" + }, "engines": { "yarn": ">= 1.0.0" } diff --git a/components/tailwind.css b/components/tailwind.css new file mode 100644 index 00000000000..6937f163520 --- /dev/null +++ b/components/tailwind.css @@ -0,0 +1,1245 @@ +@import "@fontsource-variable/work-sans"; +@import "tailwindcss"; +@plugin "@tailwindcss/forms"; + +/* Scan Django templates and JS for class usage */ +@source "../dojo/templates/**/*.html"; +@source "../dojo/static/dojo/js/**/*.js"; + +/* ============================================================ + DefectDojo Design Tokens + ============================================================ */ +@theme { + /* Primary palette — Fuji Blue (DefectDojo brand) */ + --color-dd-primary-50: #e8f3fb; + --color-dd-primary-100: #C6DDF2; + --color-dd-primary-200: #82B0D9; + --color-dd-primary-300: #5094CC; + --color-dd-primary-400: #2E87C6; + --color-dd-primary-500: #1779C5; + --color-dd-primary-600: #1467AD; + --color-dd-primary-700: #204D87; + --color-dd-primary-800: #0F3C6E; + --color-dd-primary-900: #003864; + + /* Accent — Torii Orange (CTA highlights, badges) */ + --color-dd-accent: #F2561D; + --color-dd-accent-light: #F2D49B; + --color-dd-accent-hover: #F2762E; + --color-dd-accent-dark: #C1230D; + + /* Surface / neutral tokens — Dojo Black palette */ + --color-surface: #ffffff; + --color-surface-2: #f7f8f9; + --color-surface-3: #f0f1f3; + --color-border: #DCDCDC; + --color-text: #191919; + --color-text-muted: #666666; + + /* Severity colors */ + --color-severity-critical: #dc2626; + --color-severity-high: #ea580c; + --color-severity-medium: #ca8a04; + --color-severity-low: #2563eb; + --color-severity-info: #6b7280; + + /* Dashboard stat card accent colors */ + --color-panel-blue: #1779C5; + --color-panel-green: #16a34a; + --color-panel-yellow: #ca8a04; + --color-panel-red: #dc2626; + + /* Badge count */ + --color-badge-count: #dc2626; + --color-badge-count-zero: #16a34a; + + /* Font stack — Work Sans (DefectDojo brand) */ + --font-sans: "Work Sans Variable", "Work Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* ============================================================ + Component Layer — Bootstrap-compatible class names via @apply + Templates can keep using btn, card, alert, etc. + ============================================================ */ +@layer components { + + /* ---- Buttons ---- */ + .btn { + @apply inline-flex items-center justify-center gap-1.5 rounded-md px-3.5 py-2 text-sm font-medium + transition-all duration-150 cursor-pointer shadow-xs + hover:shadow-md hover:-translate-y-px + focus:outline-none focus:ring-2 focus:ring-offset-1 + disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-xs; + min-height: 2.25rem; /* 36px — comfortable touch target */ + } + .btn-primary { + @apply bg-dd-primary-500 text-white hover:bg-dd-primary-700 focus:ring-dd-primary-400; + } + .btn-default, .btn-secondary { + @apply bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 hover:border-gray-400 focus:ring-dd-primary-300; + } + .btn-success { + @apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-400; + } + .btn-danger { + @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-400; + } + .btn-warning { + @apply bg-yellow-500 text-white hover:bg-yellow-600 focus:ring-yellow-300; + } + .btn-info { + @apply bg-dd-primary-400 text-white hover:bg-dd-primary-500 focus:ring-dd-primary-300; + } + .btn-link { + @apply bg-transparent text-dd-primary-500 hover:text-dd-primary-700 hover:underline shadow-none p-0; + min-height: auto; + } + .btn-sm { + @apply px-2.5 py-1 text-xs; + min-height: 1.75rem; /* 28px */ + } + .btn-xs { + @apply px-2 py-0.5 text-xs; + min-height: 1.5rem; /* 24px */ + } + .btn-lg { + @apply px-5 py-2.5 text-base; + min-height: 2.75rem; /* 44px — full mobile touch target */ + } + .btn-circle { + @apply rounded-full w-10 h-10 p-0 flex items-center justify-center; + } + .btn-group { + @apply inline-flex; + } + .btn-group > .btn { + @apply rounded-none first:rounded-l last:rounded-r; + } + /* Button group mobile: wrap + restore rounding + touch targets */ + @media (max-width: 640px) { + .btn-group { + flex-wrap: wrap; + gap: 0.25rem; + } + .btn-group > .btn { + border-radius: 0.375rem; + } + .btn { + min-height: 2.5rem; + } + .btn-xs { + min-height: 2rem; + } + } + + /* ---- Cards (panels) ---- */ + .card, .panel, .panel-default { + @apply bg-surface rounded-lg border border-border shadow-sm transition-shadow duration-200; + } + .card-header, .panel-heading { + @apply px-5 py-3.5 border-b border-border bg-surface-2 font-semibold text-sm text-text rounded-t-lg; + } + .card-body, .panel-body { + @apply p-4; + } + .card-footer, .panel-footer { + @apply px-4 py-3 border-t border-border bg-surface-2 rounded-b-lg; + } + /* Colored panel headings — used on delete pages, reports, etc. */ + .panel-primary > .panel-heading { + @apply bg-dd-primary-500 text-white border-dd-primary-500; + } + .panel-danger > .panel-heading { + @apply bg-panel-red text-white border-panel-red; + } + .panel-warning > .panel-heading { + @apply bg-amber-500 text-white border-amber-500; + } + .panel-success > .panel-heading { + @apply bg-panel-green text-white border-panel-green; + } + .panel-info > .panel-heading { + @apply bg-dd-primary-400 text-white border-dd-primary-400; + } + + /* ---- Alerts ---- */ + .alert { + @apply px-4 py-3 rounded-lg border mb-4 text-sm; + } + .alert-success { + @apply bg-green-50 border-green-200 text-green-800; + } + .alert-danger, .alert-error { + @apply bg-red-50 border-red-200 text-red-800; + } + .alert-warning { + @apply bg-yellow-50 border-yellow-200 text-yellow-800; + } + .alert-info { + @apply bg-blue-50 border-blue-200 text-blue-800; + } + .alert-dismissible { + @apply pr-10 relative; + } + .alert-dismissible > .close { + @apply absolute top-3 right-3 text-lg leading-none opacity-60 hover:opacity-100 cursor-pointer bg-transparent border-none; + } + + /* ---- Typography ---- */ + h1, .h1 { + @apply text-2xl font-semibold tracking-tight text-text; + } + h2, .h2 { + @apply text-xl font-semibold text-text; + } + h3, .h3 { + @apply text-lg font-medium text-text; + } + h4, .h4 { + @apply text-base font-medium text-text; + } + h5, .h5 { + @apply text-sm font-semibold text-text; + } + h6, .h6 { + @apply text-xs font-semibold uppercase tracking-wide text-text-muted; + } + .text-muted { + @apply text-text-muted; + } + small, .small { + @apply text-xs text-text-muted; + } + .lead { + @apply text-lg text-text-muted font-light; + } + + /* ---- Inline code & code blocks ---- + Inline : pink/red on a tinted background like GitHub-flavored + markdown. Block
: light gray surface, padded, with horizontal
+     scroll inside the box so long single-line commands (e.g. curl) do
+     NOT overflow the page wrapper and push content under the sidebar. */
+  code {
+    @apply px-1.5 py-0.5 rounded bg-red-50 text-red-600 text-[0.85em];
+    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco,
+                 Consolas, "Liberation Mono", "Courier New", monospace;
+  }
+  pre {
+    @apply block bg-surface-3 border border-border rounded-md
+           px-4 py-3 my-3 text-sm leading-relaxed overflow-x-auto;
+    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco,
+                 Consolas, "Liberation Mono", "Courier New", monospace;
+    /* `
` inside should stay raw; clear the inline-code styling so
+       a  nested inside a 
 doesn't get the pink background. */
+  }
+  pre code {
+    @apply p-0 bg-transparent text-inherit text-sm;
+  }
+
+  /* ---- Tables ---- */
+  .table {
+    @apply w-full text-sm text-left border-collapse;
+  }
+  .table th {
+    /* Title-case headers (no uppercase / letter-spacing) keep column widths
+       compact so wide tables fit in the viewport — matches the classic UI's
+       responsive flow at common laptop widths. */
+    @apply px-3 py-2.5 bg-surface-2 font-semibold text-text-muted border-b-2 border-border
+           text-left text-xs sticky top-0 z-10;
+  }
+  .table td {
+    @apply px-3 py-2.5 border-b border-border/50;
+    /* Allow long values (e.g. URLs, finding titles) to wrap inside their
+       cell so columns can shrink with the viewport instead of forcing the
+       table to overflow horizontally. Use `break-word` (not `anywhere`)
+       so the browser only breaks inside a word when that word is too long
+       to fit on its own line — preventing mid-word splits when normal
+       space-wrap opportunities are available. */
+    overflow-wrap: break-word;
+  }
+  .table tbody tr {
+    @apply transition-colors duration-100;
+  }
+  .table-striped tbody tr:nth-child(odd) {
+    @apply bg-surface-2/50;
+  }
+  .table-hover tbody tr:hover {
+    @apply bg-dd-primary-50/50;
+  }
+  .table-condensed th, .table-condensed td {
+    @apply px-2 py-1;
+  }
+  .table-responsive {
+    /* `overflow-x: scroll` keeps the horizontal scrollbar slot reserved at
+       all times so users on macOS / iOS — where overlay scrollbars hide
+       until interaction — can see the scroll affordance for wide tables.
+       Combined with thin webkit styling to keep it unobtrusive. */
+    overflow-x: scroll;
+    scrollbar-width: thin;
+    scrollbar-color: rgb(203 213 225) transparent;
+  }
+  .table-responsive::-webkit-scrollbar {
+    height: 10px;
+    width: 10px;
+  }
+  .table-responsive::-webkit-scrollbar-track {
+    background: transparent;
+  }
+  .table-responsive::-webkit-scrollbar-thumb {
+    background: rgb(203 213 225);
+    border-radius: 6px;
+  }
+  .table-responsive::-webkit-scrollbar-thumb:hover {
+    background: rgb(148 163 184);
+  }
+  /* Sort indicators for tablesorter-bootstrap (non-DataTables tables) */
+  .tablesorter-bootstrap thead th {
+    cursor: pointer;
+    position: relative;
+    padding-right: 1.5rem;
+  }
+  .tablesorter-bootstrap thead th::after {
+    content: "\2195"; /* ↕ up-down arrow */
+    position: absolute;
+    right: 0.5rem;
+    top: 50%;
+    transform: translateY(-50%);
+    opacity: 0.25;
+    font-size: 0.65rem;
+  }
+  .tablesorter-bootstrap thead th.tablesorter-headerAsc::after {
+    content: "\25B2"; /* ▲ */
+    opacity: 0.7;
+    color: var(--color-dd-primary-500);
+  }
+  .tablesorter-bootstrap thead th.tablesorter-headerDesc::after {
+    content: "\25BC"; /* ▼ */
+    opacity: 0.7;
+    color: var(--color-dd-primary-500);
+  }
+  /* Table + responsive mobile enhancements */
+  @media (max-width: 640px) {
+    .table th, .table td {
+      padding: 0.5rem;
+      font-size: 0.75rem;
+    }
+    .table th {
+      font-size: 0.65rem;
+    }
+  }
+  /* Visual fade hint at the right edge so users always see that there is
+     more content to scroll, regardless of viewport width or whether the
+     OS is rendering an overlay scrollbar. */
+  .table-responsive {
+    background:
+      linear-gradient(to right, white 30%, rgba(255,255,255,0)) left,
+      linear-gradient(to left, white 30%, rgba(255,255,255,0)) right;
+    background-size: 40px 100%;
+    background-repeat: no-repeat;
+    background-attachment: local, local;
+  }
+
+  /* ---- Forms ---- */
+  .form-group {
+    @apply mb-4;
+  }
+  .control-label {
+    @apply block text-sm font-medium text-slate-700 mb-1.5;
+  }
+  .form-control {
+    @apply block w-full rounded-md border-border shadow-xs text-sm
+           px-3 py-2 bg-white
+           transition-all duration-150
+           focus:border-dd-primary-500 focus:ring-2 focus:ring-dd-primary-200/40 focus:outline-none;
+    min-height: 2.5rem;  /* 40px — consistent with button heights */
+  }
+  textarea.form-control {
+    @apply h-auto min-h-[5rem];
+  }
+  select.form-control {
+    @apply pr-8;
+  }
+  .help-block {
+    @apply text-xs text-slate-500 mt-1;
+  }
+  .has-error .form-control {
+    @apply border-red-500 focus:border-red-500 focus:ring-red-500/20;
+  }
+  .has-error .help-block {
+    @apply text-red-600;
+  }
+  .has-error .control-label {
+    @apply text-red-600;
+  }
+  /* Form section — a card wrapper for logical form grouping */
+  .form-section {
+    @apply bg-white rounded-lg border border-border p-5 mb-6;
+  }
+  .form-section-title {
+    @apply text-base font-semibold text-text mb-4 pb-2 border-b border-border;
+  }
+  .input-group {
+    @apply flex;
+  }
+  .input-group .form-control {
+    @apply rounded-r-none;
+  }
+  .input-group-btn {
+    @apply flex;
+  }
+  .input-group-btn .btn {
+    @apply rounded-l-none;
+  }
+  /* Read-only form display */
+  .form-control-static {
+    @apply py-2 px-0 text-sm text-text min-h-[2.5rem] flex items-center;
+  }
+
+  /* ---- Grid (Bootstrap compat) ---- */
+  .row {
+    @apply grid grid-cols-12 gap-4;
+  }
+  /* Children of .row without col-* class default to full width (BS3 compat) */
+  .row > :not([class*="col-"]) {
+    grid-column: 1 / -1;
+  }
+
+  /* -- Mobile defaults: sm/md/lg columns stack full-width below breakpoint -- */
+  .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6,
+  .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {
+    @apply col-span-12;
+  }
+  .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6,
+  .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {
+    @apply col-span-12;
+  }
+  .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6,
+  .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {
+    @apply col-span-12;
+  }
+
+  /* -- xs: always applies (no breakpoint, like BS3) — AFTER defaults to win cascade -- */
+  .col-12, .col-xs-12 { @apply col-span-12; }
+  .col-xs-11 { @apply col-span-11; }
+  .col-xs-10 { @apply col-span-10; }
+  .col-xs-9  { @apply col-span-9; }
+  .col-xs-8  { @apply col-span-8; }
+  .col-xs-7  { @apply col-span-7; }
+  .col-xs-6  { @apply col-span-6; }
+  .col-xs-5  { @apply col-span-5; }
+  .col-xs-4  { @apply col-span-4; }
+  .col-xs-3  { @apply col-span-3; }
+  .col-xs-2  { @apply col-span-2; }
+  .col-xs-1  { @apply col-span-1; }
+  .col-xs-offset-1  { @apply col-start-2; }
+  .col-xs-offset-2  { @apply col-start-3; }
+  .col-xs-offset-3  { @apply col-start-4; }
+  .col-xs-offset-4  { @apply col-start-5; }
+  .col-xs-offset-5  { @apply col-start-6; }
+  .col-xs-offset-6  { @apply col-start-7; }
+
+  /* -- sm: ≥768px (Bootstrap 3 small breakpoint) -- */
+  @media (min-width: 768px) {
+    .col-sm-1  { @apply col-span-1; }
+    .col-sm-2  { @apply col-span-2; }
+    .col-sm-3  { @apply col-span-3; }
+    .col-sm-4  { @apply col-span-4; }
+    .col-sm-5  { @apply col-span-5; }
+    .col-sm-6  { @apply col-span-6; }
+    .col-sm-7  { @apply col-span-7; }
+    .col-sm-8  { @apply col-span-8; }
+    .col-sm-9  { @apply col-span-9; }
+    .col-sm-10 { @apply col-span-10; }
+    .col-sm-11 { @apply col-span-11; }
+    .col-sm-12 { @apply col-span-12; }
+    .col-sm-offset-1  { @apply col-start-2; }
+    .col-sm-offset-2  { @apply col-start-3; }
+    .col-sm-offset-3  { @apply col-start-4; }
+    .col-sm-offset-4  { @apply col-start-5; }
+    .col-sm-offset-5  { @apply col-start-6; }
+    .col-sm-offset-6  { @apply col-start-7; }
+    .col-sm-offset-10 { @apply col-start-11; }
+  }
+
+  /* -- md: ≥992px (Bootstrap 3 medium breakpoint) -- */
+  @media (min-width: 992px) {
+    .col-md-1  { @apply col-span-1; }
+    .col-md-2  { @apply col-span-2; }
+    .col-md-3  { @apply col-span-3; }
+    .col-md-4  { @apply col-span-4; }
+    .col-md-5  { @apply col-span-5; }
+    .col-md-6  { @apply col-span-6; }
+    .col-md-7  { @apply col-span-7; }
+    .col-md-8  { @apply col-span-8; }
+    .col-md-9  { @apply col-span-9; }
+    .col-md-10 { @apply col-span-10; }
+    .col-md-11 { @apply col-span-11; }
+    .col-md-12 { @apply col-span-12; }
+    .col-md-offset-1  { @apply col-start-2; }
+    .col-md-offset-2  { @apply col-start-3; }
+    .col-md-offset-3  { @apply col-start-4; }
+    .col-md-offset-4  { @apply col-start-5; }
+    .col-md-offset-5  { @apply col-start-6; }
+    .col-md-offset-6  { @apply col-start-7; }
+    .col-md-offset-10 { @apply col-start-11; }
+  }
+
+  /* -- lg: ≥1200px (Bootstrap 3 large breakpoint) -- */
+  @media (min-width: 1200px) {
+    .col-lg-1  { @apply col-span-1; }
+    .col-lg-2  { @apply col-span-2; }
+    .col-lg-3  { @apply col-span-3; }
+    .col-lg-4  { @apply col-span-4; }
+    .col-lg-5  { @apply col-span-5; }
+    .col-lg-6  { @apply col-span-6; }
+    .col-lg-7  { @apply col-span-7; }
+    .col-lg-8  { @apply col-span-8; }
+    .col-lg-9  { @apply col-span-9; }
+    .col-lg-10 { @apply col-span-10; }
+    .col-lg-11 { @apply col-span-11; }
+    .col-lg-12 { @apply col-span-12; }
+    .col-lg-offset-1  { @apply col-start-2; }
+    .col-lg-offset-2  { @apply col-start-3; }
+    .col-lg-offset-3  { @apply col-start-4; }
+    .col-lg-offset-4  { @apply col-start-5; }
+    .col-lg-offset-5  { @apply col-start-6; }
+    .col-lg-offset-6  { @apply col-start-7; }
+    .col-lg-offset-10 { @apply col-start-11; }
+  }
+
+  /* ---- Badges ---- */
+  .badge {
+    @apply inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs font-medium;
+  }
+  .badge-count {
+    @apply bg-badge-count text-white;
+  }
+  .badge-count0 {
+    @apply bg-badge-count-zero text-white;
+  }
+  .label {
+    @apply inline-flex items-center rounded px-2 py-0.5 text-xs font-medium;
+  }
+  .label-default {
+    @apply bg-gray-200 text-gray-700;
+  }
+  .label-primary {
+    @apply bg-dd-primary-500 text-white;
+  }
+  .label-success {
+    @apply bg-green-100 text-green-800;
+  }
+  .label-danger {
+    @apply bg-red-100 text-red-800;
+  }
+  .label-warning {
+    @apply bg-yellow-100 text-yellow-800;
+  }
+  .label-info {
+    @apply bg-blue-100 text-blue-800;
+  }
+
+  /* ---- Dropdowns (Alpine.js powered) ---- */
+  .dropdown {
+    @apply relative;
+  }
+  .dropdown-menu {
+    @apply absolute z-50 mt-1.5 min-w-52 rounded-lg bg-white border border-gray-200 shadow-lg py-1.5;
+  }
+  .dropdown-menu > li > a, .dropdown-menu > a {
+    @apply block px-4 py-2 text-sm text-gray-700 hover:bg-dd-primary-50 hover:text-dd-primary-700
+           transition-colors duration-100;
+  }
+  .dropdown-menu .divider, .dropdown-menu > li.divider {
+    @apply border-t border-gray-200 my-1;
+  }
+
+  /* ---- Navs / Tabs ---- */
+  .nav {
+    @apply list-none p-0 m-0;
+  }
+  .nav-tabs {
+    @apply flex border-b border-gray-200 gap-0 overflow-x-auto;
+    -webkit-overflow-scrolling: touch;
+    scrollbar-width: none;
+  }
+  .nav-tabs::-webkit-scrollbar {
+    display: none;
+  }
+  /* The horizontal-bar variant (product / engagement / test tab strips)
+     hosts dropdown menus inside its tab items. Per CSS spec a single
+     overflow-x: auto forces overflow-y to clip too, which would hide the
+     dropdown menus that pop down below the bar. Switch to flex-wrap so
+     tabs wrap onto a second row on narrow viewports while dropdowns can
+     still escape the tab strip vertically. */
+  .nav-tabs.horizontal-bar {
+    @apply flex-wrap;
+    overflow: visible;
+  }
+  .nav-tabs > li {
+    @apply -mb-px flex-shrink-0;
+  }
+  .nav-tabs > li > a {
+    @apply block px-4 py-2.5 text-sm text-gray-500 hover:text-gray-700 border-b-2 border-transparent
+           hover:border-gray-300 no-underline transition-colors duration-150;
+  }
+  .nav-tabs > li.active > a, .nav-tabs > li > a.active {
+    @apply text-dd-primary-600 border-dd-primary-500 font-semibold;
+  }
+  .tab-content > .tab-pane {
+    @apply hidden;
+  }
+  .tab-content > .tab-pane.active {
+    @apply block;
+  }
+
+  /* ---- Breadcrumbs ---- */
+  .breadcrumb {
+    @apply flex flex-wrap items-center gap-0.5 text-sm text-slate-500 py-2 px-0 mb-4 list-none bg-transparent;
+  }
+  .breadcrumb > li a {
+    @apply text-text-muted hover:text-dd-primary-600 transition-colors duration-150;
+  }
+  .breadcrumb > li + li::before {
+    content: "\203A"; /* › single right-pointing angle quotation mark */
+    @apply px-1.5 text-slate-300 text-base;
+  }
+  .breadcrumb > li.active > a,
+  .breadcrumb > li:last-child > a {
+    @apply text-slate-900 font-semibold no-underline cursor-default;
+  }
+
+  /* ---- Modals (using ) ---- */
+  .modal, dialog.modal {
+    @apply fixed inset-0 z-50 m-auto p-0 rounded-lg shadow-xl border-0
+           w-[calc(100%-2rem)] max-w-lg bg-white;
+  }
+  dialog.modal::backdrop {
+    @apply bg-black/50;
+  }
+  .modal-header {
+    @apply flex items-center justify-between px-5 py-4 border-b border-gray-200;
+  }
+  .modal-title {
+    @apply text-lg font-semibold;
+  }
+  .modal-body {
+    @apply px-5 py-4;
+  }
+  .modal-footer {
+    @apply flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200;
+  }
+
+  /* Modal mobile padding */
+  @media (max-width: 640px) {
+    .modal-header, .modal-body, .modal-footer {
+      padding-left: 0.75rem;
+      padding-right: 0.75rem;
+    }
+  }
+
+  /* ---- Well ---- */
+  .well {
+    @apply rounded-md bg-gray-100 p-4 border border-gray-200;
+  }
+
+  /* ---- Tooltips (Alpine hover) ---- */
+  .has-popover {
+    @apply relative cursor-help;
+  }
+
+  /* ---- Severity tag colors ---- */
+  .tag-label {
+    @apply inline-flex items-center rounded px-2 py-0.5 text-xs font-bold text-white;
+  }
+
+  /* ---- Pagination ---- */
+  .pagination {
+    @apply flex items-center gap-0 list-none p-0 m-0;
+  }
+  .pagination > li > a {
+    @apply block px-3 py-1.5 text-sm text-gray-600 border border-gray-200 -ml-px
+           hover:bg-dd-primary-50 hover:text-dd-primary-700 no-underline transition-colors duration-100;
+  }
+  .pagination > li:first-child > a {
+    @apply rounded-l-md ml-0;
+  }
+  .pagination > li:last-child > a {
+    @apply rounded-r-md;
+  }
+  .pagination > li.active > a {
+    @apply bg-dd-primary-500 text-white border-dd-primary-500 z-10;
+  }
+  .pagination-sm > li > a {
+    @apply px-2 py-1 text-xs;
+  }
+  /* Pagination mobile: smaller, centered, stacked floats */
+  @media (max-width: 640px) {
+    .pagination {
+      flex-wrap: wrap;
+      justify-content: center;
+      gap: 0;
+      margin: 0.5rem 0;
+    }
+    .pagination > li > a {
+      padding: 0.25rem 0.5rem;
+      font-size: 0.75rem;
+    }
+    .clearfix > .pull-left,
+    .clearfix > .pull-right {
+      float: none;
+      display: block;
+      text-align: center;
+    }
+  }
+
+  /* ---- Progress bars ---- */
+  .progress {
+    @apply h-5 overflow-hidden rounded bg-gray-200;
+  }
+  .progress-bar {
+    @apply h-full flex items-center justify-center text-xs font-semibold text-white transition-all duration-500;
+  }
+  .progress-bar-success { @apply bg-green-500; }
+  .progress-bar-warning { @apply bg-yellow-500; }
+  .progress-bar-danger { @apply bg-red-500; }
+  .progress-bar-info { @apply bg-blue-500; }
+
+  /* ---- List groups ---- */
+  .list-group {
+    @apply list-none p-0 m-0 rounded-lg border border-gray-200;
+  }
+  .list-group-item {
+    @apply block px-4 py-3 border-b border-gray-200 last:border-b-0 first:rounded-t-lg last:rounded-b-lg
+           transition-colors duration-100;
+  }
+  .list-group-item:hover {
+    @apply bg-dd-primary-50;
+  }
+  .list-group-item.active {
+    @apply bg-dd-primary-500 text-white border-dd-primary-500;
+  }
+
+  /* ---- Inline forms ---- */
+  .form-inline {
+    @apply flex flex-wrap items-end gap-2;
+  }
+  .form-inline .form-group {
+    @apply mb-0;
+  }
+  .form-inline .form-control {
+    @apply w-auto inline-block;
+  }
+
+  /* ---- Button variants ---- */
+  .btn-outline-secondary {
+    @apply bg-transparent text-gray-600 border border-gray-300 hover:bg-gray-100 hover:text-gray-800
+           focus:ring-gray-300;
+  }
+  .btn-outline-primary {
+    @apply bg-transparent text-dd-primary-500 border border-dd-primary-500
+           hover:bg-dd-primary-500 hover:text-white focus:ring-dd-primary-400;
+  }
+
+  /* ---- Bootstrap utility compat ---- */
+  .d-flex { @apply flex; }
+  .d-inline-flex { @apply inline-flex; }
+  .d-block { @apply block; }
+  .d-inline-block { @apply inline-block; }
+  .d-none { @apply hidden; }
+  .justify-content-end { @apply justify-end; }
+  .justify-content-center { @apply justify-center; }
+  .justify-content-between { @apply justify-between; }
+  .justify-content-start { @apply justify-start; }
+  .align-items-center { @apply items-center; }
+  .align-items-start { @apply items-start; }
+  .align-items-end { @apply items-end; }
+  .flex-wrap { @apply flex-wrap; }
+  .gap-1 { @apply gap-1; }
+  .gap-2 { @apply gap-2; }
+  .gap-3 { @apply gap-3; }
+  .me-1 { @apply mr-1; }
+  .me-2 { @apply mr-2; }
+  .me-3 { @apply mr-3; }
+  .ms-1 { @apply ml-1; }
+  .ms-2 { @apply ml-2; }
+  .ms-auto { @apply ml-auto; }
+  .mb-0 { @apply mb-0; }
+  .mb-1 { @apply mb-1; }
+  .mb-2 { @apply mb-2; }
+  .mb-3 { @apply mb-3; }
+  .mb-4 { @apply mb-4; }
+  .mt-1 { @apply mt-1; }
+  .mt-2 { @apply mt-2; }
+  .mt-3 { @apply mt-3; }
+  .mt-4 { @apply mt-4; }
+  .p-0 { @apply p-0; }
+  .p-2 { @apply p-2; }
+  .p-3 { @apply p-3; }
+  .p-4 { @apply p-4; }
+  .w-100 { @apply w-full; }
+  .w-auto { @apply w-auto; }
+
+  /* ---- Form label (Bootstrap 5 compat) ---- */
+  .form-label {
+    @apply block text-sm font-medium text-text mb-1;
+  }
+
+  /* ---- Filter UI components ---- */
+  .has-filters {
+    @apply flex justify-between items-center;
+  }
+  .panel-heading.tight, .card-header.tight {
+    @apply px-3 py-1.5;
+  }
+  .filter-set {
+    @apply block;
+  }
+  .is-filters {
+    @apply bg-surface-2 border-t border-border;
+  }
+  .toggle-filters {
+    @apply transition-transform duration-200;
+  }
+  .toggle-filters .caret,
+  .toggle-filters .fa-caret-down {
+    @apply transition-transform duration-200;
+  }
+  .dropup .caret,
+  .dropup .fa-caret-down {
+    transform: rotate(180deg);
+  }
+
+  /* ---- Disabled state for anchor buttons ---- */
+  a.disabled, .btn.disabled {
+    @apply opacity-50 cursor-not-allowed;
+    pointer-events: none;
+  }
+
+  /* ---- Misc Bootstrap compat ---- */
+  .text-muted { @apply text-text-muted; }
+  .text-danger { @apply text-red-600; }
+  .text-success { @apply text-green-600; }
+  .text-warning { @apply text-yellow-600; }
+  .text-info { @apply text-blue-600; }
+  .text-primary { @apply text-dd-primary-500; }
+  .text-center { @apply text-center; }
+  .text-right { @apply text-right; }
+  .text-left { @apply text-left; }
+  .pull-left { @apply float-left; }
+  .pull-right { @apply float-right; }
+  .clearfix { @apply after:content-[''] after:table after:clear-both; }
+  .hidden-xs { @apply max-sm:hidden; }
+  .hidden { @apply hidden; }
+  .no-margin-top { @apply mt-0; }
+  .container-fluid { @apply w-full px-4; }
+  .close {
+    @apply text-xl leading-none opacity-60 hover:opacity-100 cursor-pointer bg-transparent border-none float-right;
+  }
+  .caret {
+    @apply inline-block w-0 h-0 ml-0.5 align-middle
+           border-t-4 border-t-current border-x-4 border-x-transparent;
+  }
+  .glyphicon.arrow {
+    @apply inline-block w-0 h-0 ml-1 align-middle
+           border-t-4 border-t-current border-x-4 border-x-transparent;
+  }
+
+  /* ---- Dashboard stat cards ---- */
+  .stat-card {
+    @apply bg-surface rounded-lg border border-border shadow-sm
+           transition-all duration-200 hover:shadow-md
+           overflow-hidden border-l-4;
+  }
+  .stat-card .stat-card-body {
+    @apply flex items-center gap-4 px-5 py-4;
+  }
+  .stat-card .stat-card-icon {
+    @apply flex items-center justify-center w-12 h-12 rounded-full text-lg shrink-0;
+  }
+  .stat-card .stat-card-content {
+    @apply flex flex-col min-w-0;
+  }
+  .stat-card .stat-card-value {
+    @apply text-3xl font-bold text-text leading-none;
+  }
+  .stat-card .stat-card-label {
+    /* Allow long labels (e.g. "Risk Accepted In Last Seven Days") to wrap
+       inside the card instead of truncating with an ellipsis — matches the
+       classic UI's behavior at 1200-1400 widths. */
+    @apply text-sm text-text-muted mt-1 leading-tight;
+  }
+  .stat-card .stat-card-link {
+    @apply flex items-center justify-between px-5 py-2.5 text-sm
+           bg-surface-2 border-t border-border text-text-muted
+           hover:text-dd-primary-500 hover:bg-surface-3 transition-colors;
+  }
+
+  /* Stat card color variants */
+  .stat-card--blue {
+    @apply border-l-panel-blue;
+  }
+  .stat-card--blue .stat-card-icon {
+    @apply bg-blue-50 text-panel-blue;
+  }
+  .stat-card--red {
+    @apply border-l-panel-red;
+  }
+  .stat-card--red .stat-card-icon {
+    @apply bg-red-50 text-panel-red;
+  }
+  .stat-card--green {
+    @apply border-l-panel-green;
+  }
+  .stat-card--green .stat-card-icon {
+    @apply bg-green-50 text-panel-green;
+  }
+  .stat-card--yellow {
+    @apply border-l-panel-yellow;
+  }
+  .stat-card--yellow .stat-card-icon {
+    @apply bg-yellow-50 text-panel-yellow;
+  }
+
+  .huge {
+    @apply text-4xl font-bold;
+  }
+
+  /* ---- Announcement banner ---- */
+  .announcement-banner {
+    @apply rounded-none mb-0;
+  }
+
+  /* ---- Skeletons (loading placeholders) ---- */
+  .skeleton {
+    @apply animate-pulse bg-slate-200 rounded;
+  }
+  .skeleton-text {
+    @apply animate-pulse bg-slate-200 rounded h-4 w-3/4;
+  }
+  .skeleton-text-sm {
+    @apply animate-pulse bg-slate-200 rounded h-3 w-1/2;
+  }
+  .skeleton-circle {
+    @apply animate-pulse bg-slate-200 rounded-full;
+  }
+  .skeleton-row {
+    @apply flex gap-3 items-center;
+  }
+  .skeleton-row .skeleton-cell {
+    @apply animate-pulse bg-slate-200 rounded h-8 flex-1;
+  }
+
+  /* ---- Empty states ---- */
+  .empty-state {
+    @apply flex flex-col items-center justify-center py-12 px-6 text-center;
+  }
+  .empty-state-icon {
+    @apply text-4xl text-slate-300 mb-4;
+  }
+  .empty-state h3 {
+    @apply text-lg font-semibold text-slate-700 mb-2;
+  }
+  .empty-state p {
+    @apply text-sm text-slate-500 max-w-md mb-4;
+  }
+
+  /* ---- Page transitions ---- */
+  .app-content {
+    animation: fadeSlideIn 200ms ease-out;
+  }
+  @keyframes fadeSlideIn {
+    from { opacity: 0; transform: translateY(4px); }
+    to   { opacity: 1; transform: translateY(0); }
+  }
+  /* htmx swap transitions */
+  .htmx-swapping {
+    opacity: 0;
+    transition: opacity 150ms ease-out;
+  }
+  .htmx-settling {
+    opacity: 1;
+    transition: opacity 150ms ease-in;
+  }
+  /* htmx loading indicator */
+  .htmx-indicator {
+    display: none;
+  }
+  .htmx-request .htmx-indicator,
+  .htmx-request.htmx-indicator {
+    display: inline-block;
+  }
+
+  /* ---- Micro-interactions ---- */
+  /* Card hover lift */
+  .card {
+    @apply transition-shadow duration-200;
+  }
+  .card:hover {
+    @apply shadow-md;
+  }
+  /* Alert dismiss animation */
+  .alert {
+    @apply transition-all duration-300;
+  }
+  .alert.fade-out {
+    opacity: 0;
+    transform: translateY(-0.5rem);
+    max-height: 0;
+    margin: 0;
+    padding-top: 0;
+    padding-bottom: 0;
+    overflow: hidden;
+  }
+
+  /* ============================================================
+     Responsive overrides (mobile-first)
+     ============================================================ */
+
+  /* Typography: scale down headings + stat values on mobile */
+  @media (max-width: 640px) {
+    h1, .h1 { font-size: 1.25rem; }
+    h2, .h2 { font-size: 1.125rem; }
+    .stat-card .stat-card-value { font-size: 1.5rem; }
+    .huge { font-size: 1.875rem; }
+  }
+
+  /* Bootstrap 3 responsive visibility utilities */
+  .visible-xs, .visible-sm, .visible-md, .visible-lg {
+    display: none !important;
+  }
+  @media (max-width: 767px) {
+    .visible-xs { display: block !important; }
+  }
+  @media (min-width: 768px) and (max-width: 991px) {
+    .visible-sm { display: block !important; }
+    .hidden-sm { display: none !important; }
+  }
+  @media (min-width: 992px) and (max-width: 1199px) {
+    .visible-md { display: block !important; }
+    .hidden-md { display: none !important; }
+  }
+  @media (min-width: 1200px) {
+    .visible-lg { display: block !important; }
+    .hidden-lg { display: none !important; }
+  }
+}
+
+/* ============================================================
+   Utility overrides
+   ============================================================ */
+@layer utilities {
+  .no-margin { margin: 0; }
+  [x-cloak] { display: none !important; }
+}
+
+/* ============================================================
+   Checkbox / radio fix — @tailwindcss/forms adds checkbox styles
+   in @layer base, which loses to Bootstrap's unlayered .checkbox
+   CSS that absolute-positions the input with negative margin,
+   collapsing it to 0px width. Override outside @layer.
+   ============================================================ */
+.checkbox label,
+.radio label {
+  padding-left: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.375rem;
+  cursor: pointer;
+}
+.checkbox input[type="checkbox"],
+.radio input[type="radio"] {
+  position: static !important;
+  margin-left: 0 !important;
+  width: 1rem;
+  height: 1rem;
+  min-width: 1rem;
+  border: 1px solid #9ca3af;
+  border-radius: 0.25rem;
+  appearance: none;
+  -webkit-appearance: none;
+  background-color: #fff;
+  display: inline-block;
+  vertical-align: middle;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+.checkbox input[type="checkbox"]:checked {
+  background-color: #1779C5;
+  border-color: #1779C5;
+  background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
+  background-size: 100% 100%;
+  background-repeat: no-repeat;
+  background-position: center;
+}
+.radio input[type="radio"] {
+  border-radius: 100%;
+}
+.radio input[type="radio"]:checked {
+  background-color: #1779C5;
+  border-color: #1779C5;
+  background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
+  background-size: 100% 100%;
+  background-repeat: no-repeat;
+  background-position: center;
+}
+.checkbox input[type="checkbox"]:focus,
+.radio input[type="radio"]:focus {
+  outline: 2px solid #1779C5;
+  outline-offset: 2px;
+}
+
+/* ============================================================
+   Dashboard stat cards — panel-heading inner row needs flex
+   layout so the icon column sizes to content instead of a fixed
+   grid fraction (fa-5x icons overflow a 3/12 grid column).
+   Outside @layer so it beats the @layer components `.row` rule.
+   ============================================================ */
+.panel-heading > .row {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+}
+.panel-heading > .row > [class*="col-xs-3"],
+.panel-heading > .row > [class*="col-xs-2"],
+.panel-heading > .row > [class*="col-xs-1"] {
+  flex: 0 0 auto;
+  width: auto;
+}
+.panel-heading > .row > [class*="col-xs-9"],
+.panel-heading > .row > [class*="col-xs-8"],
+.panel-heading > .row > [class*="col-xs-10"] {
+  flex: 1 1 0%;
+  min-width: 0;
+}
+
+/* ============================================================
+   CSS-only tooltips for .has-popover (outside layer for priority)
+   ============================================================ */
+.has-popover[data-content] {
+  position: relative;
+  cursor: help;
+}
+.has-popover[data-content]:hover::after,
+.has-popover[data-content]:focus::after,
+.has-popover[data-content]:focus-within::after {
+  content: attr(data-content);
+  position: absolute;
+  bottom: 100%;
+  left: 50%;
+  transform: translateX(-50%);
+  margin-bottom: 4px;
+  padding: 6px 10px;
+  background-color: #1f2937;
+  color: white;
+  font-size: 0.75rem;
+  line-height: 1.4;
+  border-radius: 4px;
+  /* width: max-content lets the popover expand beyond its tiny icon parent
+     (which is `position: relative` and otherwise constrains the absolute
+     child to ~icon width, forcing text to wrap one char per line). */
+  width: max-content;
+  max-width: 320px;
+  white-space: normal;
+  z-index: 9999;
+  pointer-events: none;
+  /* Reset Font Awesome inheritance so text renders as normal text */
+  font-family: "Work Sans Variable", "Work Sans", system-ui, -apple-system, sans-serif;
+  font-weight: 400;
+  font-style: normal;
+  text-transform: none;
+  -webkit-font-smoothing: auto;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+}
+
+/* ============================================================
+   Bootstrap collapse compat shim (outside layer for priority)
+   ============================================================ */
+.collapse { display: none !important; visibility: hidden !important; }
+.collapse.in { display: block !important; visibility: visible !important; }
+
+/* ============================================================
+   Bootstrap dropdown compat — since bootstrap.min.css is not
+   loaded, we provide the essential dropdown visibility rules.
+   The JS shim in index.js toggles .open on the parent element.
+   ============================================================ */
+.dropdown-menu {
+  display: none;
+  position: absolute;
+  z-index: 1000;
+  min-width: 13rem;
+  padding: 6px 0;
+  margin: 4px 0 0;
+  background-color: #fff;
+  border: 1px solid #DCDCDC;
+  border-radius: 0.5rem;
+  box-shadow: 0 4px 16px rgba(0,0,0,.12), 0 1px 3px rgba(0,0,0,.06);
+}
+.open > .dropdown-menu { display: block; }
+.dropdown-menu > li > a,
+.dropdown-menu > a {
+  display: block;
+  padding: 4px 20px;
+  font-size: 0.875rem;
+  color: #333;
+  text-decoration: none;
+  white-space: nowrap;
+}
+.dropdown-menu > li > a:hover,
+.dropdown-menu > li > a:focus,
+.dropdown-menu > a:hover,
+.dropdown-menu > a:focus {
+  background-color: #e8f3fb;  /* dd-primary-50 */
+  color: #204D87;  /* dd-primary-700 */
+}
+.dropdown-menu .divider,
+.dropdown-menu li[role="separator"] {
+  height: 1px;
+  margin: 4px 0;
+  overflow: hidden;
+  background-color: #DCDCDC;
+  border: 0;
+}
+
+/* ============================================================
+   Announcement banner — dojo.css sets negative margins
+   (-15px / -30px) to span Bootstrap's container-fluid padding.
+   Our new layout has no such padding, so reset to zero.
+   ============================================================ */
+.announcement-banner {
+  margin-left: 0 !important;
+  margin-right: 0 !important;
+  border-radius: 0;
+}
+
+/* ============================================================
+   Checkbox / Radio fix — dojo.css (line 936) sets
+     input[type="checkbox"] { display: inline; border: 0; }
+   which collapses checkboxes to 0×0 when the @tailwindcss/forms
+   plugin applies appearance: none.  Reset to inline-block with
+   proper sizing so the plugin's custom check/radio rendering works.
+   ============================================================ */
+input[type="checkbox"],
+input[type="radio"] {
+  display: inline-block !important;
+  width: 1rem !important;
+  height: 1rem !important;
+  border-width: 1px !important;
+  border-style: solid !important;
+  flex-shrink: 0;
+}
+
+/* The Bootstrap 3 standalone-column width fallback used to live here. The new
+   Tailwind UI uses a vertical-label form layout where the form-group children
+   should fill the available column width and adapt to viewport size. Fixed
+   percentage widths on standalone col-* classes prevented inputs from
+   stretching on wide screens and forced labels to wrap on narrow ones, so the
+   fallback was removed. The in-grid behaviour (.row > .col-*) is unaffected
+   and continues to be applied via grid-column above. */
+
+/* The Bootstrap 3 horizontal-form offset fallback (margin-left: N% on standalone
+   col-*-offset-*) used to be applied here. The new Tailwind UI stacks labels on
+   top of inputs (vertical layout), so the offset would push checkboxes, submit
+   buttons and other action rows out of alignment with the form fields. The
+   in-grid behaviour (.row > .col-*-offset-*) defined above is unaffected. */
diff --git a/components/yarn.lock b/components/yarn.lock
index 6791acee5b4..358ce9bcaa0 100644
--- a/components/yarn.lock
+++ b/components/yarn.lock
@@ -2,6 +2,79 @@
 # yarn lockfile v1
 
 
+"@emnapi/core@^1.8.1":
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
+  integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
+  dependencies:
+    "@emnapi/wasi-threads" "1.2.1"
+    tslib "^2.4.0"
+
+"@emnapi/runtime@^1.8.1":
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
+  integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
+  dependencies:
+    tslib "^2.4.0"
+
+"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
+  integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
+  dependencies:
+    tslib "^2.4.0"
+
+"@fontsource-variable/work-sans@^5.1":
+  version "5.2.8"
+  resolved "https://registry.yarnpkg.com/@fontsource-variable/work-sans/-/work-sans-5.2.8.tgz#ac91b1b0b7d8e2d2f862537e7de8451d49548279"
+  integrity sha512-8uWtTt0/B5NxGie9xUVD5y5Ch4Q+Hy7kFYKtUpwYbzSAgJEoaMxT8rMnfnK7zfAYSLC8GnGO1/tXrFtKIYYQVQ==
+
+"@jridgewell/gen-mapping@^0.3.5":
+  version "0.3.13"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
+  integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
+  dependencies:
+    "@jridgewell/sourcemap-codec" "^1.5.0"
+    "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/remapping@^2.3.5":
+  version "2.3.5"
+  resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
+  integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
+  dependencies:
+    "@jridgewell/gen-mapping" "^0.3.5"
+    "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/resolve-uri@^3.1.0":
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+  integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
+"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+  integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@jridgewell/trace-mapping@^0.3.24":
+  version "0.3.31"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0"
+  integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
+  dependencies:
+    "@jridgewell/resolve-uri" "^3.1.0"
+    "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@kurkle/color@^0.3.0":
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
+  integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
+
+"@napi-rs/wasm-runtime@^1.1.1":
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
+  integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
+  dependencies:
+    "@tybys/wasm-util" "^0.10.1"
+
 "@noble/ciphers@^1.0.0":
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.3.0.tgz#f64b8ff886c240e644e5573c097f86e5b43676dc"
@@ -12,17 +85,231 @@
   resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a"
   integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
 
+"@parcel/watcher-android-arm64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564"
+  integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==
+
+"@parcel/watcher-darwin-arm64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e"
+  integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==
+
+"@parcel/watcher-darwin-x64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063"
+  integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==
+
+"@parcel/watcher-freebsd-x64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53"
+  integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==
+
+"@parcel/watcher-linux-arm-glibc@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a"
+  integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==
+
+"@parcel/watcher-linux-arm-musl@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152"
+  integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==
+
+"@parcel/watcher-linux-arm64-glibc@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809"
+  integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==
+
+"@parcel/watcher-linux-arm64-musl@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4"
+  integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==
+
+"@parcel/watcher-linux-x64-glibc@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639"
+  integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==
+
+"@parcel/watcher-linux-x64-musl@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2"
+  integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==
+
+"@parcel/watcher-win32-arm64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e"
+  integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==
+
+"@parcel/watcher-win32-ia32@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d"
+  integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==
+
+"@parcel/watcher-win32-x64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d"
+  integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==
+
+"@parcel/watcher@^2.5.1":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1"
+  integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==
+  dependencies:
+    detect-libc "^2.0.3"
+    is-glob "^4.0.3"
+    node-addon-api "^7.0.0"
+    picomatch "^4.0.3"
+  optionalDependencies:
+    "@parcel/watcher-android-arm64" "2.5.6"
+    "@parcel/watcher-darwin-arm64" "2.5.6"
+    "@parcel/watcher-darwin-x64" "2.5.6"
+    "@parcel/watcher-freebsd-x64" "2.5.6"
+    "@parcel/watcher-linux-arm-glibc" "2.5.6"
+    "@parcel/watcher-linux-arm-musl" "2.5.6"
+    "@parcel/watcher-linux-arm64-glibc" "2.5.6"
+    "@parcel/watcher-linux-arm64-musl" "2.5.6"
+    "@parcel/watcher-linux-x64-glibc" "2.5.6"
+    "@parcel/watcher-linux-x64-musl" "2.5.6"
+    "@parcel/watcher-win32-arm64" "2.5.6"
+    "@parcel/watcher-win32-ia32" "2.5.6"
+    "@parcel/watcher-win32-x64" "2.5.6"
+
 "@swc/helpers@^0.5.12":
-  version "0.5.18"
-  resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.18.tgz#feeeabea0d10106ee25aaf900165df911ab6d3b1"
-  integrity sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.21.tgz#0b1b020317ee1282860ca66f7e9a7c7790f05ae0"
+  integrity sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==
   dependencies:
     tslib "^2.8.0"
 
+"@tailwindcss/cli@^4.1":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/cli/-/cli-4.2.4.tgz#daa21d626fa91965e2700b6e989d76365ba16e56"
+  integrity sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA==
+  dependencies:
+    "@parcel/watcher" "^2.5.1"
+    "@tailwindcss/node" "4.2.4"
+    "@tailwindcss/oxide" "4.2.4"
+    enhanced-resolve "^5.19.0"
+    mri "^1.2.0"
+    picocolors "^1.1.1"
+    tailwindcss "4.2.4"
+
+"@tailwindcss/forms@^0.5":
+  version "0.5.11"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.11.tgz#e77039e96fa7b87c3d001a991f77f9418e666700"
+  integrity sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==
+  dependencies:
+    mini-svg-data-uri "^1.2.3"
+
+"@tailwindcss/node@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.2.4.tgz#1f7fc0c1741037ded1fa92fbe62a786a197771ce"
+  integrity sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==
+  dependencies:
+    "@jridgewell/remapping" "^2.3.5"
+    enhanced-resolve "^5.19.0"
+    jiti "^2.6.1"
+    lightningcss "1.32.0"
+    magic-string "^0.30.21"
+    source-map-js "^1.2.1"
+    tailwindcss "4.2.4"
+
+"@tailwindcss/oxide-android-arm64@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz#d533e52ee98d58f55d1d4753774251513ba8a911"
+  integrity sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==
+
+"@tailwindcss/oxide-darwin-arm64@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz#2a6250aa7d8791fc1b5797e64e09e51da57514a6"
+  integrity sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==
+
+"@tailwindcss/oxide-darwin-x64@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz#d647299812946b6ab5140c61a334c8ebc8d877de"
+  integrity sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==
+
+"@tailwindcss/oxide-freebsd-x64@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz#019b7fce37aaf5ddfed0f231c536108292e87ffb"
+  integrity sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==
+
+"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz#c88a95d69095e84f811b302daa66f5287ad8ce0f"
+  integrity sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==
+
+"@tailwindcss/oxide-linux-arm64-gnu@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz#1292f1c222994bfe4a5e990ac0a701de6487dd02"
+  integrity sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==
+
+"@tailwindcss/oxide-linux-arm64-musl@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz#afb6492b22616f0d9d3346d39c1a6e285f994a08"
+  integrity sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==
+
+"@tailwindcss/oxide-linux-x64-gnu@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz#400b0ccfc53937c7804ed8e0e9652b42bd86f2eb"
+  integrity sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==
+
+"@tailwindcss/oxide-linux-x64-musl@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz#5c23c476e5de4ed9cd6ab39c2718b9a4be2bbb2b"
+  integrity sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==
+
+"@tailwindcss/oxide-wasm32-wasi@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz#21b7f53ba7c6c03f26ccb8cef5d09f5c2973ae5e"
+  integrity sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==
+  dependencies:
+    "@emnapi/core" "^1.8.1"
+    "@emnapi/runtime" "^1.8.1"
+    "@emnapi/wasi-threads" "^1.1.0"
+    "@napi-rs/wasm-runtime" "^1.1.1"
+    "@tybys/wasm-util" "^0.10.1"
+    tslib "^2.8.1"
+
+"@tailwindcss/oxide-win32-arm64-msvc@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz#13bc1cf3818e3345a965d36b40c237817124d070"
+  integrity sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==
+
+"@tailwindcss/oxide-win32-x64-msvc@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz#5476dbbbf6b8934d58452340cec737fdaa5ec8c6"
+  integrity sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==
+
+"@tailwindcss/oxide@4.2.4":
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.2.4.tgz#e2ca51d04e8ad94d569222fa727de479b097db39"
+  integrity sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==
+  optionalDependencies:
+    "@tailwindcss/oxide-android-arm64" "4.2.4"
+    "@tailwindcss/oxide-darwin-arm64" "4.2.4"
+    "@tailwindcss/oxide-darwin-x64" "4.2.4"
+    "@tailwindcss/oxide-freebsd-x64" "4.2.4"
+    "@tailwindcss/oxide-linux-arm-gnueabihf" "4.2.4"
+    "@tailwindcss/oxide-linux-arm64-gnu" "4.2.4"
+    "@tailwindcss/oxide-linux-arm64-musl" "4.2.4"
+    "@tailwindcss/oxide-linux-x64-gnu" "4.2.4"
+    "@tailwindcss/oxide-linux-x64-musl" "4.2.4"
+    "@tailwindcss/oxide-wasm32-wasi" "4.2.4"
+    "@tailwindcss/oxide-win32-arm64-msvc" "4.2.4"
+    "@tailwindcss/oxide-win32-x64-msvc" "4.2.4"
+
+"@tybys/wasm-util@^0.10.1":
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
+  integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
+  dependencies:
+    tslib "^2.4.0"
+
 "@types/codemirror@^5.60.10":
-  version "5.60.16"
-  resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.16.tgz#1f462f9771113bd8e1c6130c666b17db8e1087c2"
-  integrity sha512-V/yHdamffSS075jit+fDxaOAmdP2liok8NSNJnAZfDJErzOheuygHZEhAJrfmk5TEyM32MhkZjwo/idX791yxw==
+  version "5.60.17"
+  resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.17.tgz#754649d285e0e775fe912ad2f5e757f22a70e1cf"
+  integrity sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==
   dependencies:
     "@types/tern" "*"
 
@@ -43,10 +330,29 @@
   dependencies:
     "@types/estree" "*"
 
+"@vue/reactivity@~3.1.1":
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.1.5.tgz#dbec4d9557f7c8f25c2635db1e23a78a729eb991"
+  integrity sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==
+  dependencies:
+    "@vue/shared" "3.1.5"
+
+"@vue/shared@3.1.5":
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.1.5.tgz#74ee3aad995d0a3996a6bb9533d4d280514ede03"
+  integrity sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==
+
 JUMFlot@jumjum123/JUMFlot#*:
   version "0.0.0"
   resolved "https://codeload.github.com/jumjum123/JUMFlot/tar.gz/203147fa2ace27db89e2defcde0800654015ae23"
 
+alpinejs@^3.14:
+  version "3.15.11"
+  resolved "https://registry.yarnpkg.com/alpinejs/-/alpinejs-3.15.11.tgz#fa0d97d080bbb0dced9b314b1584a6c8f87f4443"
+  integrity sha512-m26gkTg/MId8O+F4jHKK3vB3SjbFxxk/JHP+qzmw1H6aQrZuPAg4CUoAefnASzzp/eNroBjrRQe7950bNeaBJw==
+  dependencies:
+    "@vue/reactivity" "~3.1.1"
+
 base64-js@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978"
@@ -87,6 +393,25 @@ brotli@^1.3.2:
   dependencies:
     base64-js "^1.1.2"
 
+browserify-zlib@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f"
+  integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==
+  dependencies:
+    pako "~1.0.5"
+
+chart.js@^4.4:
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35"
+  integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==
+  dependencies:
+    "@kurkle/color" "^0.3.0"
+
+chartjs-adapter-moment@^1.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.1.tgz#0f04c30d330b207c14bfb57dfaae9ce332f09102"
+  integrity sha512-Uz+nTX/GxocuqXpGylxK19YG4R3OSVf8326D+HwSTsNw1LgzyIGRo+Qujwro1wy6X+soNSnfj5t2vZ+r6EaDmA==
+
 "chosen-bootstrap@https://github.com/dbtek/chosen-bootstrap":
   version "0.0.0"
   resolved "https://github.com/dbtek/chosen-bootstrap#12dcd363d1482c54c740ed9fe0e92549d81e9176"
@@ -118,21 +443,21 @@ codemirror-spell-checker@1.1.2:
     typo-js "*"
 
 codemirror@^5.65.15:
-  version "5.65.19"
-  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.19.tgz#71016c701d6a4b6e1982b0f6e7186be65e49653d"
-  integrity sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==
+  version "5.65.21"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.21.tgz#cacf320606c5450ad3b3da34bb9c666afec21068"
+  integrity sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==
 
 core-util-is@~1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
   integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
 
-datatables.net-bs@^2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/datatables.net-bs/-/datatables.net-bs-2.3.2.tgz#51899f06d1c5f652be52b3daa4998604c967d544"
-  integrity sha512-Idp0weFEVezSZXJHXbmIIlS84PWuh9UkCvXnCS4l6SR2spVDMKqg1pBQBQkjjxYBnNK/fJzxdNNBdpjsvXOu8w==
+datatables.net-bs@^2, datatables.net-bs@^2.3.7:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/datatables.net-bs/-/datatables.net-bs-2.3.8.tgz#1dd57f2320e2fb1e928aa4a809fca7b65550f37e"
+  integrity sha512-lM8sriL2WNDPimRi/Yh5tD+u1mWcaUa8EOo34dyDWaIBcOwWy78tdSnIjVobngyFYer3AieR83QEEyih+IR+Aw==
   dependencies:
-    datatables.net "2.3.2"
+    datatables.net "2.3.8"
     jquery ">=1.7"
 
 datatables.net-buttons-bs@^3.2.6:
@@ -144,7 +469,7 @@ datatables.net-buttons-bs@^3.2.6:
     datatables.net-buttons "3.2.6"
     jquery ">=1.7"
 
-datatables.net-buttons@3.2.6:
+datatables.net-buttons@3.2.6, datatables.net-buttons@^3.2.0:
   version "3.2.6"
   resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-3.2.6.tgz#dad80c8f28eb18741cec49fb33397073217ca63e"
   integrity sha512-rLqkB3xLIAYwVLt+lUSxybo/1WqveTAxhQm6wj6yvXlJiWq+oJ8MKW6H1q90QrXbNp0fGngnfD0cmpMZnNnnNw==
@@ -179,6 +504,11 @@ delegate@^3.1.2:
   resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
   integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
 
+detect-libc@^2.0.3:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
+  integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
+
 dfa@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657"
@@ -209,6 +539,14 @@ easymde@^2.21.0:
     codemirror-spell-checker "1.1.2"
     marked "^4.1.0"
 
+enhanced-resolve@^5.19.0:
+  version "5.21.0"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz#bb8e6fabaf74930de70e61397798750429e5b1ae"
+  integrity sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==
+  dependencies:
+    graceful-fs "^4.2.4"
+    tapable "^2.3.3"
+
 eve-raphael@0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30"
@@ -219,6 +557,11 @@ fast-deep-equal@^3.1.3:
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
+flatpickr@^4.6:
+  version "4.6.13"
+  resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.13.tgz#8a029548187fd6e0d670908471e43abe9ad18d94"
+  integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
+
 flot@flot/flot#~0.8.3:
   version "0.8.3"
   resolved "https://codeload.github.com/flot/flot/tar.gz/453b017cc5acfd75e252b93e8635f57f4196d45d"
@@ -265,6 +608,16 @@ google-code-prettify@^1.0.0:
   resolved "https://registry.yarnpkg.com/google-code-prettify/-/google-code-prettify-1.0.5.tgz#9f477f224dbfa62372e5ef803a7e157410400084"
   integrity sha512-Y47Bw63zJKCuqTuhTZC1ct4e/0ADuMssxXhnrP8QHq71tE2aYBKG6wQwXr8zya0zIUd0mKN3XTlI5AME4qm6NQ==
 
+graceful-fs@^4.2.4:
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
+htmx.org@^2.0:
+  version "2.0.10"
+  resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.10.tgz#62442b0e2952a885ae2e50a7654b8b20d0981134"
+  integrity sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==
+
 immediate@~3.0.5:
   version "3.0.6"
   resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
@@ -275,11 +628,28 @@ inherits@~2.0.3:
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-glob@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
 isarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
 
+jiti@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92"
+  integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
+
 jquery-highlight@3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/jquery-highlight/-/jquery-highlight-3.5.0.tgz#354fb3a8b98c594525ec1ccc003fd3d1dc305815"
@@ -308,7 +678,12 @@ jquery.hotkeys@jeresig/jquery.hotkeys#master:
   version "0.2.0"
   resolved "https://codeload.github.com/jeresig/jquery.hotkeys/tar.gz/f24f1da275aab7881ab501055c256add6f690de4"
 
-"jquery@>= 1.0.0", "jquery@>=1.12.0 <5.0.0", jquery@>=1.7, jquery@>=1.7.0, jquery@^3.7.1:
+"jquery@>= 1.0.0", "jquery@>=1.12.0 <5.0.0", jquery@>=1.7, jquery@>=1.7.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jquery/-/jquery-4.0.0.tgz#95c33ac29005ff72ec444c5ba1cf457e61404fbb"
+  integrity sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==
+
+jquery@^3.7.1:
   version "3.7.1"
   resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de"
   integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==
@@ -342,6 +717,80 @@ lie@~3.3.0:
   dependencies:
     immediate "~3.0.5"
 
+lightningcss-android-arm64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
+  integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
+
+lightningcss-darwin-arm64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
+  integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
+
+lightningcss-darwin-x64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
+  integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
+
+lightningcss-freebsd-x64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
+  integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
+
+lightningcss-linux-arm-gnueabihf@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
+  integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
+
+lightningcss-linux-arm64-gnu@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
+  integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
+
+lightningcss-linux-arm64-musl@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
+  integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
+
+lightningcss-linux-x64-gnu@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
+  integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
+
+lightningcss-linux-x64-musl@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
+  integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
+
+lightningcss-win32-arm64-msvc@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
+  integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
+
+lightningcss-win32-x64-msvc@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
+  integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
+
+lightningcss@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
+  integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
+  dependencies:
+    detect-libc "^2.0.3"
+  optionalDependencies:
+    lightningcss-android-arm64 "1.32.0"
+    lightningcss-darwin-arm64 "1.32.0"
+    lightningcss-darwin-x64 "1.32.0"
+    lightningcss-freebsd-x64 "1.32.0"
+    lightningcss-linux-arm-gnueabihf "1.32.0"
+    lightningcss-linux-arm64-gnu "1.32.0"
+    lightningcss-linux-arm64-musl "1.32.0"
+    lightningcss-linux-x64-gnu "1.32.0"
+    lightningcss-linux-x64-musl "1.32.0"
+    lightningcss-win32-arm64-msvc "1.32.0"
+    lightningcss-win32-x64-msvc "1.32.0"
+
 linebreak@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b"
@@ -350,6 +799,13 @@ linebreak@^1.1.0:
     base64-js "0.0.8"
     unicode-trie "^2.0.0"
 
+magic-string@^0.30.21:
+  version "0.30.21"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
+  integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+  dependencies:
+    "@jridgewell/sourcemap-codec" "^1.5.5"
+
 marked@^4.1.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3"
@@ -360,6 +816,11 @@ metismenu@~3.0.7:
   resolved "https://registry.yarnpkg.com/metismenu/-/metismenu-3.0.7.tgz#613dd01d14d053474b926a1ecac24d137c934aaa"
   integrity sha512-omMwIAahlzssjSi3xY9ijkhXI8qEaQTqBdJ9lHmfV5Bld2UkxO2h2M3yWsteAlGJ/nSHi4e69WHDE2r18Ickyw==
 
+mini-svg-data-uri@^1.2.3:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
+  integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
+
 moment@^2.30.1:
   version "2.30.1"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
@@ -369,12 +830,22 @@ morris.js@morrisjs/morris.js:
   version "0.5.1"
   resolved "https://codeload.github.com/morrisjs/morris.js/tar.gz/14530d0733801d5bef1264cf3d062ecace7e326b"
 
+mri@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
+  integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
+
+node-addon-api@^7.0.0:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
+  integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
+
 pako@^0.2.5:
   version "0.2.9"
   resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
   integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
 
-pako@~1.0.2:
+pako@~1.0.2, pako@~1.0.5:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
   integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
@@ -400,10 +871,22 @@ pdfmake@^0.3.7:
     pdfkit "^0.18.0"
     xmldoc "^2.0.3"
 
+picocolors@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+  integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^4.0.3:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
+  integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
+
 png-js@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/png-js/-/png-js-1.0.0.tgz#e5484f1e8156996e383aceebb3789fd75df1874d"
-  integrity sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/png-js/-/png-js-1.1.0.tgz#60a135216601f807b88a6d61ac93bd42a32c5ee1"
+  integrity sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==
+  dependencies:
+    browserify-zlib "^0.2.0"
 
 process-nextick-args@~2.0.0:
   version "2.0.1"
@@ -441,9 +924,9 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
 sax@^1.4.3:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.3.tgz#fcebae3b756cdc8428321805f4b70f16ec0ab5db"
-  integrity sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
+  integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==
 
 select@^1.1.2:
   version "1.1.2"
@@ -455,6 +938,11 @@ setimmediate@^1.0.5:
   resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
   integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
 
+source-map-js@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+  integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
 startbootstrap-sb-admin-2@1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/startbootstrap-sb-admin-2/-/startbootstrap-sb-admin-2-1.0.7.tgz#ef36a90903afb4a84a25c329b0292d06bf05b130"
@@ -467,6 +955,16 @@ string_decoder@~1.1.1:
   dependencies:
     safe-buffer "~5.1.0"
 
+tailwindcss@4.2.4, tailwindcss@^4.1:
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.2.4.tgz#f7e3090edb22d56394db4d68e6464d2628dc2aa9"
+  integrity sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==
+
+tapable@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
+  integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
+
 tiny-emitter@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
@@ -477,15 +975,15 @@ tiny-inflate@^1.0.0, tiny-inflate@^1.0.3:
   resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
   integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
 
-tslib@^2.8.0:
+tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1:
   version "2.8.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
   integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
 
 typo-js@*:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.2.5.tgz#0aa65e0be9b69036463a3827de8185b4144e3086"
-  integrity sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.3.1.tgz#c80f8c7b292caaa17a507226bf74c1cddf6a29e6"
+  integrity sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg==
 
 unicode-properties@^1.4.0:
   version "1.4.1"
diff --git a/docs/content/admin/sso/OS__auth0.md b/docs/content/admin/sso/OS__auth0.md
deleted file mode 100644
index e603a020cba..00000000000
--- a/docs/content/admin/sso/OS__auth0.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-title: "Auth0"
-description: "Configure Auth0 SSO in Open-Source DefectDojo"
-weight: 4
-audience: opensource
----
-
-Open-Source DefectDojo supports login via Auth0. DefectDojo Pro users should refer to the [Pro Auth0 guide](/admin/sso/pro__auth0/).
-
-## Prerequisites
-
-Complete the following steps in your Auth0 dashboard before configuring DefectDojo:
-
-1. Create a new application: **Applications > Create Application > Single Page Web Application**.
-
-2. Configure the application:
-   - **Name:** `DefectDojo`
-   - **Allowed Callback URLs:** `https://your-instance.cloud.defectdojo.com/complete/auth0/`
-
-3. Note the following values — you will need them in DefectDojo:
-   - **Domain**
-   - **Client ID**
-   - **Client Secret**
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED=True
-DD_SOCIAL_AUTH_AUTH0_KEY=(str, 'YOUR_CLIENT_ID'),
-DD_SOCIAL_AUTH_AUTH0_SECRET=(str, 'YOUR_CLIENT_SECRET'),
-DD_SOCIAL_AUTH_AUTH0_DOMAIN=(str, 'YOUR_AUTH0_DOMAIN'),
-{{< /highlight >}}
-
-Restart DefectDojo. A **Login with Auth0** button will appear on the login page.
diff --git a/docs/content/admin/sso/OS__azure_ad.md b/docs/content/admin/sso/OS__azure_ad.md
deleted file mode 100644
index 3af21e2a86c..00000000000
--- a/docs/content/admin/sso/OS__azure_ad.md
+++ /dev/null
@@ -1,72 +0,0 @@
----
-title: "Azure Active Directory"
-description: "Configure Azure AD SSO and group mapping in Open-Source DefectDojo"
-weight: 6
-audience: opensource
----
-
-Open-Source DefectDojo supports login via Azure Active Directory (Azure AD), including automatic User Group synchronization. DefectDojo Pro users should refer to the [Pro Azure AD guide](/admin/sso/pro__azure_ad/).
-
-## Prerequisites
-
-Complete the following steps in the Azure portal before configuring DefectDojo:
-
-1. [Register a new app](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) in Azure Active Directory.
-
-2. Note the following values from the registered app:
-   - **Application (client) ID**
-   - **Directory (tenant) ID**
-   - Under **Certificates & Secrets**, create a new **Client Secret** and note its value
-
-3. Under **Authentication > Redirect URIs**, add a **Web** type URI:
-   `https://your-instance.cloud.defectdojo.com/complete/azuread-tenant-oauth2/`
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY=(str, 'YOUR_APPLICATION_ID'),
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET=(str, 'YOUR_CLIENT_SECRET'),
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID=(str, 'YOUR_DIRECTORY_ID'),
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_ENABLED=True
-{{< /highlight >}}
-
-Restart DefectDojo. A **Login with Azure AD** button will appear on the login page.
-
-## Group Mapping
-
-To import User Group membership from Azure AD, set the following variable:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS=True
-{{< /highlight >}}
-
-On login, DefectDojo will assign the user to all groups found in the Azure AD token. Any groups not found in DefectDojo will be created automatically. This allows product access to be governed via groups.
-
-### Configuring Azure AD to send groups
-
-The Azure AD token must be configured to include group IDs. Without this, no group information will be present in the token.
-
-To configure this:
-1. Add a [Group Claim](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-fed-group-claims) to the token. If unsure which group type to select, choose **All Groups**.
-2. Do **not** enable **Emit groups as role claims**.
-3. Update the application's API permissions to include `GroupMember.Read.All` or `Group.Read.All`. `GroupMember.Read.All` is recommended as it grants fewer permissions.
-
-### Filtering groups
-
-To limit which groups are imported, use a regex filter:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER='^team-.*'  # or 'teamA|teamB|groupC'
-{{< /highlight >}}
-
-### Automatic Group Cleanup
-
-To remove stale groups when users are removed from them in Azure AD:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS=True
-{{< /highlight >}}
-
-When a user is removed from a group in Azure AD, they are also removed from the corresponding group in DefectDojo. Empty groups are left in place for record purposes.
diff --git a/docs/content/admin/sso/OS__github_enterprise.md b/docs/content/admin/sso/OS__github_enterprise.md
deleted file mode 100644
index 8648403c888..00000000000
--- a/docs/content/admin/sso/OS__github_enterprise.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-title: "GitHub Enterprise"
-description: "Configure GitHub Enterprise SSO in Open-Source DefectDojo"
-weight: 8
-audience: opensource
----
-
-Open-Source DefectDojo supports login via GitHub Enterprise. DefectDojo Pro users should refer to the [Pro GitHub Enterprise guide](/admin/sso/pro__github_enterprise/).
-
-## Prerequisites
-
-Complete the following steps in GitHub Enterprise before configuring DefectDojo:
-
-1. [Create a new OAuth App](https://docs.github.com/en/enterprise-server/developers/apps/building-oauth-apps/creating-an-oauth-app) in your GitHub Enterprise Server.
-
-2. Choose a name for the application, e.g. `DefectDojo`.
-
-3. Set the **Redirect URI**:
-   `https://your-dojo-host:your-port/complete/github-enterprise/`
-
-4. Note the **Client ID** and **Client Secret** from the app.
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY=(str, 'YOUR_CLIENT_ID'),
-DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET=(str, 'YOUR_CLIENT_SECRET'),
-DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_URL=(str, 'https://github.yourcompany.com/'),
-DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL=(str, 'https://github.yourcompany.com/api/v3/'),
-DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_OAUTH2_ENABLED=True,
-{{< /highlight >}}
-
-Restart DefectDojo. A **Login with GitHub Enterprise** button will appear on the login page.
diff --git a/docs/content/admin/sso/OS__gitlab.md b/docs/content/admin/sso/OS__gitlab.md
deleted file mode 100644
index 611e6ccef3f..00000000000
--- a/docs/content/admin/sso/OS__gitlab.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: "GitLab"
-description: "Configure GitLab SSO in Open-Source DefectDojo"
-weight: 10
-audience: opensource
----
-
-Open-Source DefectDojo supports login via GitLab. DefectDojo Pro users should refer to the [Pro GitLab guide](/admin/sso/pro__gitlab/).
-
-## Prerequisites
-
-Complete the following steps in GitLab before configuring DefectDojo:
-
-1. Navigate to your GitLab profile's Applications page:
-   - GitLab.com: `https://gitlab.com/profile/applications`
-   - Self-hosted: `https://your-gitlab-host/profile/applications`
-
-2. Create a new application:
-   - **Name:** `DefectDojo`
-   - **Redirect URI:** `https://your-dojo-host/complete/gitlab/`
-
-3. Note the **Application ID** and **Secret** from the application.
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GITLAB_KEY=(str, 'YOUR_APPLICATION_ID'),
-DD_SOCIAL_AUTH_GITLAB_SECRET=(str, 'YOUR_SECRET'),
-DD_SOCIAL_AUTH_GITLAB_API_URL=(str, 'https://gitlab.com'),
-DD_SOCIAL_AUTH_GITLAB_OAUTH2_ENABLED=True
-{{< /highlight >}}
-
-Restart DefectDojo. A **Login with GitLab** button will appear on the login page.
-
-### Auto-importing GitLab projects
-
-To automatically import your GitLab projects as DefectDojo Products, add the following variable:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GITLAB_PROJECT_AUTO_IMPORT=True
-{{< /highlight >}}
-
-**Note:** Enabling this on an existing instance with a GitLab integration will require users to re-grant the `read_repository` permission.
diff --git a/docs/content/admin/sso/OS__google.md b/docs/content/admin/sso/OS__google.md
deleted file mode 100644
index fc9e4eedee0..00000000000
--- a/docs/content/admin/sso/OS__google.md
+++ /dev/null
@@ -1,59 +0,0 @@
----
-title: "Google Auth"
-description: "Configure Google OAuth in Open-Source DefectDojo"
-weight: 12
-audience: opensource
----
-
-Open-Source DefectDojo supports login via Google accounts. New users are created automatically on first login if they don't already exist. Existing DefectDojo users are matched to Google accounts by username (the portion before the `@` in their Google email). DefectDojo Pro users should refer to the [Pro Google guide](/admin/sso/pro__google/).
-
-## Prerequisites
-
-Complete the following steps in the Google Cloud Console before configuring DefectDojo:
-
-1. Sign in to the [Google Developers Console](https://console.developers.google.com).
-
-2. Go to **Credentials > Create Credentials > OAuth Client ID**.
-
-3. Select **Web Application** and give it a descriptive name (e.g. `DefectDojo`).
-
-4. Under **Authorized Redirect URIs**, add:
-   `https://your-dojo-host/complete/google-oauth2/`
-
-5. Note the **Client ID** and **Client Secret Key**.
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED=True,
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=(str, 'YOUR_CLIENT_ID'),
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=(str, 'YOUR_CLIENT_SECRET'),
-{{< /highlight >}}
-
-You must also authorize which users can log in. You can whitelist by domain:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS=['example.com', 'example.org']
-{{< /highlight >}}
-
-Or as an environment variable (comma-separated):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS=example.com,example.org
-{{< /highlight >}}
-
-Alternatively, whitelist specific email addresses:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS=['user@example.com']
-{{< /highlight >}}
-
-Or as an environment variable:
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS=user@example.com,user2@example.com
-{{< /highlight >}}
-
-Restart DefectDojo. A **Login With Google** button will appear on the login page.
diff --git a/docs/content/admin/sso/OS__keycloak.md b/docs/content/admin/sso/OS__keycloak.md
deleted file mode 100644
index e1c3fd8ed85..00000000000
--- a/docs/content/admin/sso/OS__keycloak.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-title: "KeyCloak"
-description: "Configure KeyCloak SSO in Open-Source DefectDojo"
-weight: 14
-audience: opensource
----
-
-Open-Source DefectDojo supports login via KeyCloak. DefectDojo Pro users should refer to the [Pro KeyCloak guide](/admin/sso/pro__keycloak/).
-
-This guide assumes you already have a KeyCloak Realm configured. If not, see the [KeyCloak documentation](https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/realms/create.html).
-
-## Prerequisites
-
-Complete the following steps in your KeyCloak realm before configuring DefectDojo:
-
-1. Add a new client with type `openid-connect`. Note the client ID.
-
-2. In the client settings:
-   - Set **Access Type** to `confidential`
-   - Under **Valid Redirect URIs**, add your DefectDojo URL, e.g. `https://your-dojo-host/*`
-   - Under **Web Origins**, add the same URL (or `+`)
-   - Under **Fine Grained OpenID Connect Configuration**:
-     - Set **User Info Signed Response Algorithm** to `RS256`
-     - Set **Request Object Signature Algorithm** to `RS256`
-   - Save the settings.
-
-3. Under **Scope**, set **Full Scope Allowed** to `off`.
-
-4. Under **Mappers**, add a custom mapper:
-   - **Name:** `aud`
-   - **Mapper Type:** `audience`
-   - **Included Audience:** select your client ID
-   - **Add ID to Token:** `off`
-   - **Add Access to Token:** `on`
-
-5. Under **Credentials**, copy the **Secret**.
-
-6. In **Realm Settings > Keys**, copy the **Public Key** (signing key).
-
-7. In **Realm Settings > General > Endpoints**, open the OpenID endpoint configuration and copy the **Authorization** and **Token** endpoint URLs.
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SESSION_COOKIE_SECURE=True,
-DD_CSRF_COOKIE_SECURE=True,
-DD_SECURE_SSL_REDIRECT=True,
-DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED=True,
-DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY=(str, 'YOUR_REALM_PUBLIC_KEY'),
-DD_SOCIAL_AUTH_KEYCLOAK_KEY=(str, 'YOUR_CLIENT_ID'),
-DD_SOCIAL_AUTH_KEYCLOAK_SECRET=(str, 'YOUR_CLIENT_SECRET'),
-DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL=(str, 'YOUR_AUTHORIZATION_ENDPOINT'),
-DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL=(str, 'YOUR_TOKEN_ENDPOINT')
-{{< /highlight >}}
-
-For Helm deployments, add the following to the `extraConfig` section:
-
-```yaml
-DD_SESSION_COOKIE_SECURE: 'True'
-DD_CSRF_COOKIE_SECURE: 'True'
-DD_SECURE_SSL_REDIRECT: 'True'
-DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED: 'True'
-DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY: ''
-DD_SOCIAL_AUTH_KEYCLOAK_KEY: ''
-DD_SOCIAL_AUTH_KEYCLOAK_SECRET: ''
-DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL: ''
-DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL: ''
-```
-
-Optionally, set `DD_SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT` to customize the login button text.
-
-Restart DefectDojo. A login button will appear on the login page with your configured text.
diff --git a/docs/content/admin/sso/OS__oidc.md b/docs/content/admin/sso/OS__oidc.md
deleted file mode 100644
index b105931ea3b..00000000000
--- a/docs/content/admin/sso/OS__oidc.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-title: "OIDC"
-description: "Configure OpenID Connect (OIDC) SSO in Open-Source DefectDojo"
-weight: 18
-audience: opensource
----
-
-Open-Source DefectDojo supports login via a generic OpenID Connect (OIDC) provider. DefectDojo Pro users should refer to the [Pro OIDC guide](/admin/sso/pro__oidc/).
-
-## Configuration
-
-Set the following required variables as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED=True,
-DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT=(str, 'https://your-oidc-provider.com'),
-DD_SOCIAL_AUTH_OIDC_KEY=(str, 'YOUR_CLIENT_ID'),
-DD_SOCIAL_AUTH_OIDC_SECRET=(str, 'YOUR_CLIENT_SECRET')
-{{< /highlight >}}
-
-The remaining OIDC configuration is auto-detected by fetching:
-`/.well-known/openid-configuration`
-
-Restart DefectDojo. A **Log In With OIDC** button will appear on the login page.
-
-## Optional Variables
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_OIDC_ID_KEY=(str, ''),                          # Key associated with OIDC user IDs
-DD_SOCIAL_AUTH_OIDC_USERNAME_KEY=(str, ''),                    # Key associated with OIDC usernames
-DD_SOCIAL_AUTH_CREATE_USER_MAPPING=(str, 'username'),          # Can also be 'email' or 'fullname'
-DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS=(list, ['']),          # Domains allowed for login
-DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS=(list, ['RS256', 'HS256']),
-DD_SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER=(str, ''),
-DD_SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL=(str, ''),
-DD_SOCIAL_AUTH_OIDC_AUTHORIZATION_URL=(str, ''),
-DD_SOCIAL_AUTH_OIDC_USERINFO_URL=(str, ''),
-DD_SOCIAL_AUTH_OIDC_JWKS_URI=(str, ''),
-DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT=(str, 'Login with OIDC'),
-{{< /highlight >}}
diff --git a/docs/content/admin/sso/OS__okta.md b/docs/content/admin/sso/OS__okta.md
deleted file mode 100644
index 7f10b785c0a..00000000000
--- a/docs/content/admin/sso/OS__okta.md
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: "Okta"
-description: "Configure Okta SSO in Open-Source DefectDojo"
-weight: 16
-audience: opensource
----
-
-Open-Source DefectDojo supports login via Okta. DefectDojo Pro users should refer to the [Pro Okta guide](/admin/sso/pro__okta/).
-
-## Prerequisites
-
-Complete the following steps in Okta before configuring DefectDojo:
-
-1. Sign in or create an account at [Okta](https://www.okta.com/developer/signup/).
-
-2. Go to **Applications** and click **Add Application**, then select **Web Applications**.
-
-3. Under **Login Redirect URLs**, add:
-   `https://your-dojo-host/complete/okta-oauth2/`
-   Also check the **Implicit** box.
-
-4. Click **Done**.
-
-5. From the **Dashboard**, note the **Org-URL**.
-
-6. Open the application and note the **Client ID** and **Client Secret**.
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-{{< highlight python >}}
-DD_SOCIAL_AUTH_OKTA_OAUTH2_ENABLED=True,
-DD_SOCIAL_AUTH_OKTA_OAUTH2_KEY=(str, 'YOUR_CLIENT_ID'),
-DD_SOCIAL_AUTH_OKTA_OAUTH2_SECRET=(str, 'YOUR_CLIENT_SECRET'),
-DD_SOCIAL_AUTH_OKTA_OAUTH2_API_URL=(str, 'https://your-org-url/oauth2'),
-{{< /highlight >}}
-
-Restart DefectDojo. A **Login With Okta** button will appear on the login page.
-
-### Redirect URI shows http instead of https
-
-If you see the error *The 'redirect_uri' parameter must be an absolute URI that is whitelisted in the client app settings* and the `redirect_uri` starts with `http://` instead of `https://`, add the following:
-
-- **Docker Compose:** `DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS=True`
-- **local_settings.py:** `SOCIAL_AUTH_REDIRECT_IS_HTTPS=True`
diff --git a/docs/content/admin/sso/OS__remote_user.md b/docs/content/admin/sso/OS__remote_user.md
deleted file mode 100644
index d2836e81643..00000000000
--- a/docs/content/admin/sso/OS__remote_user.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: "RemoteUser"
-description: "Configure RemoteUser authentication in Open-Source DefectDojo"
-weight: 19
-audience: opensource
----
-
-RemoteUser authentication is suitable when DefectDojo is deployed behind an HTTP authentication proxy. The proxy handles authentication and passes user information to DefectDojo via HTTP headers.
-
-**Warning:** The proxy must be configured to strip any attacker-supplied headers matching the `DD_AUTH_REMOTEUSER_*` variable names before forwarding requests to DefectDojo, to prevent header spoofing. See the [Django documentation](https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#configuration) for details.
-
-## Configuration
-
-Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-| Variable | Required | Description |
-|---|---|---|
-| `DD_AUTH_REMOTEUSER_ENABLED` | Yes | Set to `True` to enable RemoteUser authentication |
-| `DD_AUTH_REMOTEUSER_USERNAME_HEADER` | Yes | Header containing the username |
-| `DD_AUTH_REMOTEUSER_TRUSTED_PROXY` | Yes | Comma-separated list of trusted proxy IPs or CIDR ranges |
-| `DD_AUTH_REMOTEUSER_EMAIL_HEADER` | No | Header containing the user's email address |
-| `DD_AUTH_REMOTEUSER_FIRSTNAME_HEADER` | No | Header containing the user's first name |
-| `DD_AUTH_REMOTEUSER_LASTNAME_HEADER` | No | Header containing the user's last name |
-| `DD_AUTH_REMOTEUSER_GROUPS_HEADER` | No | Header containing a comma-separated list of groups; the user will be assigned to these groups, and any missing groups will be created |
-| `DD_AUTH_REMOTEUSER_GROUPS_CLEANUP` | No | When `True`, removes the user from any groups not present in the current request's group header |
-| `DD_AUTH_REMOTEUSER_LOGIN_ONLY` | No | See [Django documentation](https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#using-remote-user-on-login-pages-only) |
-
-## User Permissions
-
-When a new user is created via RemoteUser (or any other SSO method), they are assigned only default permissions and cannot add, edit, or delete anything in DefectDojo.
-
-To grant permissions to new users automatically, configure the following in **System Settings**:
-
-- **Default group** — assign new users to a specific group on creation
-- **Default group role** — set the role new users receive within that group
-
-For group-based access via the `DD_AUTH_REMOTEUSER_GROUPS_HEADER`, permissions are inherited from the groups the user belongs to. See [User Permissions](../../user_management/set_user_permissions/) and [User Groups](../../user_management/create_user_group/) for more information.
diff --git a/docs/content/admin/sso/OS__saml.md b/docs/content/admin/sso/OS__saml.md
deleted file mode 100644
index f92a7725c7e..00000000000
--- a/docs/content/admin/sso/OS__saml.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-title: "SAML Configuration"
-description: "Configure SAML in Open-Source DefectDojo"
-weight: 2
-audience: opensource
-aliases:
-  - /en/working_with_findings/sla_configuration
----
-
-Open-Source DefectDojo supports SAML authentication via environment variables. DefectDojo Pro users should refer to the [Pro SAML guide](/admin/sso/pro__saml/).
-
-## Setup
-
-1. Navigate to your SAML Identity Provider and locate your metadata.
-
-2. Set the following as environment variables, or without the `DD_` prefix in your `local_settings.py` file (see [Configuration](/get_started/open_source/configuration/)):
-
-   {{< highlight python >}}
-   DD_SAML2_ENABLED=(bool, True),
-   # Login button text shown on the DefectDojo login page
-   DD_SAML2_LOGIN_BUTTON_TEXT=(str, 'Login with SAML'),
-   # If the metadata is accessible from a URL:
-   DD_SAML2_METADATA_AUTO_CONF_URL=(str, 'https://your_IdP.com/metadata.xml'),
-   # Otherwise, download the metadata as an XML file and set the path:
-   DD_SAML2_METADATA_LOCAL_FILE_PATH=(str, '/path/to/your/metadata.xml'),
-   # Map SAML assertion attributes to DefectDojo user fields:
-   DD_SAML2_ATTRIBUTES_MAP=(dict, {
-       # Format: 'SAML attribute': 'django_user_field'
-       'Email': 'email',
-       'UserName': 'username',
-       'Firstname': 'first_name',
-       'Lastname': 'last_name'
-   }),
-   {{< /highlight >}}
-
-   **Note:** In Kubernetes, `DD_SAML2_ATTRIBUTES_MAP` can be set in `extraConfig` as:
-   `DD_SAML2_ATTRIBUTES_MAP: 'Email'='email', 'Username'='username'...`
-
-   **Note:** `DD_SITE_URL` may also need to be set depending on whether you use a metadata URL or a local file.
-
-3. Review the SAML section in `dojo/settings/settings.dist.py` to verify the configuration matches your requirements. See the [djangosaml2 plugin documentation](https://djangosaml2.readthedocs.io/contents/setup.html#configuration) for further options.
-
-4. Restart DefectDojo. A **Login with SAML** button will appear on the login page.
-
-**Note:** If your IdP uses a self-signed certificate, set the `REQUESTS_CA_BUNDLE` environment variable to the path of your private CA certificate.
-
-## Advanced Configuration
-
-The [djangosaml2](https://github.com/IdentityPython/djangosaml2) plugin supports many additional options. All DefectDojo defaults can be overridden in `local_settings.py`. For example, to customize the organization name:
-
-{{< highlight python >}}
-if SAML2_ENABLED:
-    SAML_CONFIG['contact_person'] = [{
-        'given_name': 'Extra',
-        'sur_name': 'Example',
-        'company': 'DefectDojo',
-        'email_address': 'dummy@defectdojo.com',
-        'contact_type': 'technical'
-    }]
-    SAML_CONFIG['organization'] = {
-        'name': [('DefectDojo', 'en')],
-        'display_name': [('DefectDojo', 'en')],
-    },
-{{< /highlight >}}
-
-## Troubleshooting
-
-The SAML Tracer browser extension can help debug SAML issues: [Chrome](https://chromewebstore.google.com/detail/saml-tracer/mpdajninpobndbfcldcmbpnnbhibjmch?hl=en), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/).
-
-## Migrating from django-saml2-auth
-
-Prior to release 1.15.0, SAML was handled by [django-saml2-auth](https://github.com/fangli/django-saml2-auth). The following parameters changed with the switch to djangosaml2:
-
-| Old parameter | Status |
-|---|---|
-| `DD_SAML2_ASSERTION_URL` | No longer needed — auto-generated |
-| `DD_SAML2_DEFAULT_NEXT_URL` | No longer needed — default forwarding is used |
-| `DD_SAML2_NEW_USER_PROFILE` | No longer supported — default profile is used |
-| `DD_SAML2_ATTRIBUTES_MAP` | Syntax has changed |
-| `DD_SAML2_CREATE_USER` | Default changed to `False` to prevent security issues |
diff --git a/docs/content/admin/sso/PRO__auth0.md b/docs/content/admin/sso/PRO__auth0.md
index 201b76a5b1e..792caadd975 100644
--- a/docs/content/admin/sso/PRO__auth0.md
+++ b/docs/content/admin/sso/PRO__auth0.md
@@ -5,7 +5,7 @@ weight: 3
 audience: pro
 ---
 
-DefectDojo Pro supports login via Auth0. Open-Source users should refer to the [Open-Source Auth0 guide](/admin/sso/os__auth0/).
+DefectDojo Pro supports login via Auth0. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Prerequisites
 
diff --git a/docs/content/admin/sso/PRO__azure_ad.md b/docs/content/admin/sso/PRO__azure_ad.md
index 278423d92b9..853bb080cdf 100644
--- a/docs/content/admin/sso/PRO__azure_ad.md
+++ b/docs/content/admin/sso/PRO__azure_ad.md
@@ -5,7 +5,7 @@ weight: 5
 audience: pro
 ---
 
-DefectDojo Pro supports login via Azure Active Directory (Azure AD), including automatic User Group synchronization. Open-Source users should refer to the [Open-Source Azure AD guide](/admin/sso/os__azure_ad/).
+DefectDojo Pro supports login via Azure Active Directory (Azure AD), including automatic User Group synchronization. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Prerequisites
 
diff --git a/docs/content/admin/sso/PRO__github_enterprise.md b/docs/content/admin/sso/PRO__github_enterprise.md
index 6211f846002..7f4e320f010 100644
--- a/docs/content/admin/sso/PRO__github_enterprise.md
+++ b/docs/content/admin/sso/PRO__github_enterprise.md
@@ -5,7 +5,7 @@ weight: 7
 audience: pro
 ---
 
-DefectDojo Pro supports login via GitHub Enterprise. Open-Source users should refer to the [Open-Source GitHub Enterprise guide](/admin/sso/os__github_enterprise/).
+DefectDojo Pro supports login via GitHub Enterprise. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Prerequisites
 
diff --git a/docs/content/admin/sso/PRO__gitlab.md b/docs/content/admin/sso/PRO__gitlab.md
index dc13511ca90..d03d8d70b8f 100644
--- a/docs/content/admin/sso/PRO__gitlab.md
+++ b/docs/content/admin/sso/PRO__gitlab.md
@@ -5,7 +5,7 @@ weight: 9
 audience: pro
 ---
 
-DefectDojo Pro supports login via GitLab. Open-Source users should refer to the [Open-Source GitLab guide](/admin/sso/os__gitlab/).
+DefectDojo Pro supports login via GitLab. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Prerequisites
 
diff --git a/docs/content/admin/sso/PRO__google.md b/docs/content/admin/sso/PRO__google.md
index b59695aadb1..e2f14531ddf 100644
--- a/docs/content/admin/sso/PRO__google.md
+++ b/docs/content/admin/sso/PRO__google.md
@@ -5,7 +5,7 @@ weight: 11
 audience: pro
 ---
 
-DefectDojo Pro supports login via Google accounts. New users are created automatically on first login if they don't already exist. Existing DefectDojo users are matched to Google accounts by username (the portion before the `@` in their Google email). Open-Source users should refer to the [Open-Source Google guide](/admin/sso/os__google/).
+DefectDojo Pro supports login via Google accounts. New users are created automatically on first login if they don't already exist. Existing DefectDojo users are matched to Google accounts by username (the portion before the `@` in their Google email). Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Prerequisites
 
diff --git a/docs/content/admin/sso/PRO__keycloak.md b/docs/content/admin/sso/PRO__keycloak.md
index 06d8fb11933..eab8d421819 100644
--- a/docs/content/admin/sso/PRO__keycloak.md
+++ b/docs/content/admin/sso/PRO__keycloak.md
@@ -5,7 +5,7 @@ weight: 13
 audience: pro
 ---
 
-DefectDojo Pro supports login via KeyCloak. Open-Source users should refer to the [Open-Source KeyCloak guide](/admin/sso/os__keycloak/).
+DefectDojo Pro supports login via KeyCloak. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 This guide assumes you already have a KeyCloak Realm configured. If not, see the [KeyCloak documentation](https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/realms/create.html).
 
diff --git a/docs/content/admin/sso/PRO__oidc.md b/docs/content/admin/sso/PRO__oidc.md
index a5d4b0f5c76..ada4c11795f 100644
--- a/docs/content/admin/sso/PRO__oidc.md
+++ b/docs/content/admin/sso/PRO__oidc.md
@@ -5,7 +5,7 @@ weight: 17
 audience: pro
 ---
 
-DefectDojo Pro supports login via a generic OpenID Connect (OIDC) provider. Open-Source users should refer to the [Open-Source OIDC guide](/admin/sso/os__oidc/).
+DefectDojo Pro supports login via a generic OpenID Connect (OIDC) provider. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Configuration
 
diff --git a/docs/content/admin/sso/PRO__okta.md b/docs/content/admin/sso/PRO__okta.md
index 7cb8d7b3f16..e318970ff6d 100644
--- a/docs/content/admin/sso/PRO__okta.md
+++ b/docs/content/admin/sso/PRO__okta.md
@@ -5,7 +5,7 @@ weight: 15
 audience: pro
 ---
 
-DefectDojo Pro supports login via Okta. Open-Source users should refer to the [Open-Source Okta guide](/admin/sso/os__okta/).
+DefectDojo Pro supports login via Okta. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Prerequisites
 
diff --git a/docs/content/admin/sso/PRO__saml.md b/docs/content/admin/sso/PRO__saml.md
index 80c7952a732..e4a7a798224 100644
--- a/docs/content/admin/sso/PRO__saml.md
+++ b/docs/content/admin/sso/PRO__saml.md
@@ -5,7 +5,7 @@ weight: 1
 audience: pro
 ---
 
-DefectDojo Pro supports SAML authentication via the **Enterprise Settings** UI. Open-Source users should refer to the [Open-Source SAML guide](/admin/sso/os__saml/).
+DefectDojo Pro supports SAML authentication via the **Enterprise Settings** UI. Open-source DefectDojo does not include SSO — see [Authorized Users](/admin/user_management/os__authorized_users/) for open-source access control.
 
 ## Setup
 
diff --git a/docs/content/admin/sso/_index.md b/docs/content/admin/sso/_index.md
index bcface4bac3..65c46706ad7 100644
--- a/docs/content/admin/sso/_index.md
+++ b/docs/content/admin/sso/_index.md
@@ -1,9 +1,9 @@
 ---
 title: "Single Sign-On"
-description: "Set Up User Permissions, SSO and Groups"
+description: "DefectDojo Pro supports SAML and a range of OAuth providers for Single Sign-On"
 summary: ""
 date: 2023-09-07T16:06:50+02:00
-lastmod: 2023-09-07T16:06:50+02:00
+lastmod: 2026-04-30T00:00:00+00:00
 draft: false
 weight: 8
 collapsed: true
@@ -14,11 +14,28 @@ seo:
   canonical: ""
   robots: ""
 exclude_search: true
+pro-feature: true
 aliases:
   - /admin/user_management/configure_sso/
+  - /admin/sso/os__saml/
+  - /admin/sso/os__auth0/
+  - /admin/sso/os__azure_ad/
+  - /admin/sso/os__github_enterprise/
+  - /admin/sso/os__gitlab/
+  - /admin/sso/os__google/
+  - /admin/sso/os__keycloak/
+  - /admin/sso/os__oidc/
+  - /admin/sso/os__okta/
+  - /admin/sso/os__remote_user/
 ---
 
-Users can connect to DefectDojo with a Username and Password, but you can also allow users to authenticate via Single Sign-On (SSO). DefectDojo supports SAML and a range of OAuth providers:
+Single Sign-On is a **DefectDojo Pro** feature. As of DefectDojo 2.59, the SSO surface — SAML, OIDC, and the bundled OAuth providers — is available only in DefectDojo Pro. Open-source DefectDojo uses local username/password login and the password-reset flow.
+
+If you're running open-source DefectDojo and want SSO, you'll need to switch to [DefectDojo Pro](https://defectdojo.com); the migration is covered in the [2.59 upgrade notes](/releases/os_upgrading/2.59/#sso-providers-are-available-in-defectdojo-pro-only). Existing user accounts and group memberships are preserved on upgrade. For access control on open-source DefectDojo, see the [Authorized Users](/admin/user_management/os__authorized_users/) page.
+
+## Supported SSO providers (DefectDojo Pro)
+
+DefectDojo Pro supports SAML and the following OAuth providers. Each guide walks through the provider-side setup and the corresponding configuration in the Pro **Enterprise Settings** UI.
 
 * **[Auth0](/admin/sso/pro__auth0/)**
 * **[Azure Active Directory](/admin/sso/pro__azure_ad/)**
@@ -30,26 +47,17 @@ Users can connect to DefectDojo with a Username and Password, but you can also a
 * **[OIDC (OpenID Connect)](/admin/sso/pro__oidc/)**
 * **[SAML](/admin/sso/pro__saml/)**
 
-SSO configuration can only be performed by a **Superuser**.
+SSO configuration in DefectDojo Pro can only be performed by a **Superuser**.
 
 **DefectDojo Pro users:** Add the IP addresses of your SAML or SSO services to the Firewall whitelist before setting up SSO. See [Firewall Rules](/get_started/pro/cloud/using-cloud-manager/#changing-your-firewall-settings) for more information.
 
-## Disabling Username / Password Login
+## Disabling Username / Password login
 
-Once SSO is configured, you may want to disable traditional username/password login.
-
-**DefectDojo Pro** users can uncheck **Allow Login via Username and Password** under **Enterprise Settings > Login Settings**.
+Once SSO is configured in DefectDojo Pro, you may want to disable the traditional username/password login form. Uncheck **Allow Login via Username and Password** under **Enterprise Settings > Login Settings**.
 
 ![image](images/pro_login_settings.png)
 
-**Open-Source** users can set the following environment variables in Docker:
-
-```yaml
-DD_SOCIAL_LOGIN_AUTO_REDIRECT: "true"
-DD_SOCIAL_AUTH_SHOW_LOGIN_FORM: "false"
-```
-
-### Login Fallback
+### Login fallback
 
 If your SSO integration stops working, you can always return to the standard login form by appending the following to your DefectDojo URL:
 
diff --git a/docs/content/admin/user_management/OS__authorized_users.md b/docs/content/admin/user_management/OS__authorized_users.md
new file mode 100644
index 00000000000..05a40cf9345
--- /dev/null
+++ b/docs/content/admin/user_management/OS__authorized_users.md
@@ -0,0 +1,60 @@
+---
+title: "Authorized Users"
+description: "How access to Products and Product Types is granted in open-source DefectDojo"
+weight: 1
+audience: opensource
+---
+
+Open-source DefectDojo controls access to Products and Product Types with the **Authorized Users** model. Each Product and Product Type has an Authorized Users panel listing the people who can see that record and the data nested under it.
+
+If you're running DefectDojo Pro, this article doesn't apply to your installation — Pro uses a richer role-based system covered in [Permissions in DefectDojo](../about_perms_and_roles/).
+
+## How access is granted
+
+There are two lists, and a user only needs to appear on one of them to gain access:
+
+- **A Product's Authorized Users list** grants access to that single Product, plus everything nested underneath it (its Engagements, Tests, Findings, and Endpoints).
+- **A Product Type's Authorized Users list** grants access to the Product Type itself **and cascades to every Product underneath it**. A user who is authorized on a Product Type does not need to also be added to each child Product — they are already covered.
+
+There are no roles, no groups, and no global roles. A user is either on the list (or is a superuser/staff member — see below), or they cannot see the Product.
+
+## Superusers and staff bypass the lists
+
+Users marked as **superuser** or **staff** in DefectDojo can see and act on every Product and Product Type regardless of the Authorized Users lists. The lists exist to grant access to non-staff users; they do not restrict staff or superusers.
+
+The first account created on a fresh DefectDojo installation is automatically a superuser.
+
+## Who can edit the lists
+
+Only **superuser** or **staff** users see the controls to add or remove people from an Authorized Users panel. Everyone else who has access to a Product or Product Type sees the panel as a read-only roster — useful for finding out who else is on the team, but not for changing membership.
+
+## Where the panel lives
+
+The Authorized Users panel appears on two pages in the classic UI:
+
+- The **Product detail page** has an Authorized Users panel for that Product. It supports two actions for staff users:
+  - **Add a user to the Product's Authorized Users list**
+  - **Remove a user from the Product's Authorized Users list**
+- The **Product Type detail page** has an Authorized Users panel for that Product Type, with the corresponding two actions:
+  - **Add a user to the Product Type's Authorized Users list**
+  - **Remove a user from the Product Type's Authorized Users list**
+
+When you remove a user from a Product Type's list, the cascade is removed too — they lose access to every child Product unless they're still on a specific Product's list, or they're a staff/superuser.
+
+## Choosing between Product and Product Type access
+
+A few rules of thumb:
+
+- If a person should see every Product under a category (for example, every Product owned by a particular team), put them on the **Product Type** list and let the cascade take care of the rest.
+- If a person should only see one specific Product, put them on that **Product**'s list.
+- If you find yourself adding the same person to many individual Products under one Product Type, that's a signal you should add them to the Product Type instead.
+
+## Coming from a previous version of DefectDojo
+
+DefectDojo open-source moved back to the Authorized Users model in version 2.59. If you're upgrading from a release that had the Members / Groups / Global Roles system, your existing access is carried forward into Authorized Users automatically by the upgrade — no manual mapping is needed.
+
+The upgrade ships with a read-only management command, `preview_legacy_authorization_migration`, that summarizes what an upgrade would change against a copy of your database. The recommended workflow is to install 2.59 in a staging environment with a snapshot of production, run the command, review the summary, and then upgrade production.
+
+If you're moving the other direction — from open-source to DefectDojo Pro — Pro ships a `reconcile_authorized_users_to_rbac` command that brings Authorized Users access forward into Pro's RBAC. It supports `--dry-run` and is idempotent.
+
+For more detail on both paths, see the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization).
diff --git a/docs/content/admin/user_management/_index.md b/docs/content/admin/user_management/_index.md
index e35cf318f4b..33a52cc5583 100644
--- a/docs/content/admin/user_management/_index.md
+++ b/docs/content/admin/user_management/_index.md
@@ -1,6 +1,6 @@
 ---
 title: "User Management"
-description: "Set Up User Permissions, SSO and Groups"
+description: "Manage users, access control, and authentication in DefectDojo"
 summary: ""
 date: 2023-09-07T16:06:50+02:00
 lastmod: 2023-09-07T16:06:50+02:00
@@ -13,4 +13,29 @@ seo:
   canonical: "" # custom canonical URL (optional)
   robots: "" # custom robot tags (optional)
 exclude_search: true
----
\ No newline at end of file
+---
+
+DefectDojo's user management surface is different in each edition. Pick the section that matches your installation.
+
+## DefectDojo Open-Source
+
+Open-source DefectDojo uses the **Authorized Users** model: a user is given access to a Product or a Product Type by being added to that record's Authorized Users list. Superusers and staff can see everything.
+
+* [Authorized Users](./os__authorized_users/) — how to grant access to Products and Product Types
+
+Authentication on open-source DefectDojo is local username/password plus the password-reset flow.
+
+## DefectDojo Pro
+
+DefectDojo Pro uses a role-based system with Members, Groups, and Global Roles. Users can also be granted SSO access through SAML or one of the supported OAuth providers.
+
+* [Permissions in DefectDojo](./about_perms_and_roles/) — overview of Roles, Memberships, Global Roles, and Configuration Permissions
+* [Set a User's Permissions](./set_user_permissions/) — assigning Roles, Global Roles, and Configuration Permissions
+* [Share permissions: User Groups](./create_user_group/) — assigning permissions to many users at once
+* [Set Permissions in Pro](./pro_permissions_overhaul/) — Pro-specific UI for managing Members and Permissions
+* [Action permission charts](./user_permission_chart/) — full reference of every permission for every Role
+* [Single Sign-On](/admin/sso/) — SAML and OAuth setup for Pro
+
+## Migrating between editions
+
+If you're moving from open-source's Authorized Users to Pro's RBAC, or upgrading from a pre-2.59 open-source release that used RBAC into the current Authorized Users model, see the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). Existing access is preserved automatically.
diff --git a/docs/content/admin/user_management/about_perms_and_roles.md b/docs/content/admin/user_management/about_perms_and_roles.md
index fdc27288eb9..9a662bfd231 100644
--- a/docs/content/admin/user_management/about_perms_and_roles.md
+++ b/docs/content/admin/user_management/about_perms_and_roles.md
@@ -1,10 +1,14 @@
 ---
 title: "Permissions in DefectDojo"
-description: "Summary of all DefectDojo permission options, in detail"
+description: "Summary of all DefectDojo Pro permission options, in detail"
 weight: 2
+audience: pro
 aliases:
   - /en/customize_dojo/user_management/about_perms_and_roles
 ---
+
+> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions.
+
 If you have a team of users working in DefectDojo, it's important to set up Role\-Based Access Control (RBAC) appropriately so that users can only access specific data. Security data is highly sensitive, and DefectDojo's options for access control allow you to be specific about each team member’s access to information.
 
 This article is an overview of how permissions in DefectDojo work.  If you would prefer to see a detailed breakdown of **each action** that can be controlled by Permissions, see our **[Permissions Chart](../user_permission_chart/)** article.
diff --git a/docs/content/admin/user_management/configure_sso.md b/docs/content/admin/user_management/configure_sso.md
deleted file mode 100644
index 1e2f89f1e6a..00000000000
--- a/docs/content/admin/user_management/configure_sso.md
+++ /dev/null
@@ -1,750 +0,0 @@
----
-title: "SSO Configuration (OAuth, SAML)"
-description: "Sign in to DefectDojo using OAuth or SAML login options"
-pro-feature: true
-aliases:
-  - /en/customize_dojo/user_management/configure_sso
----
-Users can connect to DefectDojo with a Username and Password, but if you prefer, you can allow users to authenticate using a Single Sign\-On or SSO method. You can set up DefectDojo to work with your own SAML Identity Provider, but we also support many OAuth methods for authentication:
-
-* **[Auth0](./#auth0-setup)**
-* **[Azure Active Directory (Azure AD)](./#azure-active-directory-setup)**
-* **[GitHub Enterprise](./#github-enterprise)**
-* **[GitLab](./#gitlab)**
-* **[Google](./#google-auth)**
-* **[KeyCloak](./#keycloak)**
-* **[Okta](./#okta)**
-
-All of these methods can only be configured by a Superuser in DefectDojo.  DefectDojo Pro users can quickly set up SSO through their system settings, while Open Source users will need to configure these settings on the back-end by setting an environment variable within Docker.  This article covers both methods of configuration.
-
-**NOTE: DefectDojo Pro users will all need to add the IP addresses of SAML or SSO services to their Firewall whitelist.  See [Firewall Rules](/get_started/pro/cloud/using-cloud-manager/#changing-your-firewall-settings) for more information.**
-
-## Disable username / password use
-You may wish to disable traditional username/password login on your instance.
-
-DefectDojo Pro users can uncheck the "Allow Login via Username and Password" box on the Login Settings form: **Enterprise Settings > Login Settings**.
-
-![image](images/pro_login_settings.png)
-
-Open-Source users can set environment variables in Docker to disable the Login form:
-
-```yaml
-DD_SOCIAL_LOGIN_AUTO_REDIRECT: "true"
-DD_SOCIAL_AUTH_SHOW_LOGIN_FORM: "false"
-```
-
-### ⚠️ Login Fallback
-In case your OAuth or SAML integration stops working, you can always return to the standard login method by adding the following to your DefectDojo URL:
-
-- `your-instance.cloud.defectdojo.com` + `/login?force_login_form`
-
-We recommend having at least one DefectDojo admin set up with a username and password as a fallback.
-​
-## Auth0 Setup
-
-Both DefectDojo Pro and Open-Source users will need to complete these steps to create an integration:
-
-1.  Inside your Auth0 dashboard, create a new application (Applications /
-    Create Application / Single Page Web Application).
-
-2.  On the new application set the following fields:
-
-    -   Name: "Defectdojo"
-    -   Allowed Callback URLs:
-        `https://your-instance.cloud.defectdojo.com/complete/auth0/`
-
-3.  Copy the following info from the application:
-    -   Domain
-    -   Client ID
-    -   Client Secret
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select Auth0, and use these values from Auth0 to complete the form:
-    - **Auth0 OAuth Key**: enter your **Client ID**
-    - **Auth0 OAuth Secret**: enter your **Client Secret**
-    - **Auth0 Domain**: enter your **Domain**.
-
-2. Check the box for 'Enable Auth0 OAuth' to add the "Login With Auth0" button to the DefectDojo login page.
-
-### Open-Source
-
-Open-Source users will need to map these variables in the local_settings.py file. (see [Configuration](/get_started/open_source/configuration/)).
-
-1. Fill out the variables as follows:
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED=True
-    DD_SOCIAL_AUTH_AUTH0_KEY=(str, '**YOUR_CLIENT_ID_FROM_STEP_ABOVE**'),
-    DD_SOCIAL_AUTH_AUTH0_SECRET=(str,'**YOUR_CLIENT_SECRET_FROM_STEP_ABOVE**'),
-    DD_SOCIAL_AUTH_AUTH0_DOMAIN=(str, '**YOUR_AUTH0_DOMAIN_FROM_STEP_ABOVE**'),
-    {{< /highlight >}}
-
-2.  Restart DefectDojo, and you should now see a **Login with Auth0**
-    button on the login page.
-
-## Azure Active Directory Setup
-
-Users can log in to DefectDojo via Azure AD.  DefectDojo can leverage Azure AD Groups to automatically import User Group membership.
-
-Both DefectDojo Pro and Open-Source users will need to complete these steps to create an integration:
-
-1.  Navigate to the following address and follow instructions to create
-    a new app registration
-
-    -   
-
-2.  Once you register an app, take note of the following information:
-
-    -   **Application (client) ID**
-    -   **Directory (tenant) ID**
-    -   Under Certificates & Secrets, create a new **Client Secret**
-    -   **Application ID URI**
-
-3.  Under Authentication > Redirect URIs, add a *WEB* type of uri where
-    the redirect points to:
-    `https://your-instance.cloud.defectdojo.com/complete/azuread-tenant-oauth2/`
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select Azure AD, and use these values from Azure to complete the form:
-    - **Azure AD OAuth Key**: enter your **Application (client) ID**
-    - **Azure AD OAuth Secret**: enter the **Client Secret** which was created in step 2
-    - **Azure AD Resource**: **by default this should be set to `https://graph.microsoft.com/`**.  This should be set a the URI which DefectDojo can use to pull additional info (such as Azure AD Group names) from the [web API](https://docs.azure.cn/en-us/entra/identity-platform/security-best-practices-for-app-registration#application-id-uri).  This field only needs to be changed if your Group Names are stored on a different API resource from the Microsoft Graph Web API.
-    - **Azure AD Tenant ID**: enter the **Directory (tenant) ID**
-    - **Azure AD Groups Filter**: here, you can enter a regex string to restrict the User Groups you wish to import.
-
-2. Check the **Enable Azure AD OAuth** box.  Submit the form, and `Login With Azure AD` will be added as an option to the Login menu.
-
-#### Pro Azure Group Mapping
-
-Group synchronization allows you to import [User Group](../create_user_group/) membership from Azure AD.  DefectDojo's User Groups govern the Products and Product Types a given user can access via [RBAC](../set_user_permissions/).
-
-To import groups from Azure AD users, you can check the **Enable Azure AD OAuth Grouping** box on the form.  All User Groups found in Azure will be matched with an existing User Group in DefectDojo.  If an imported Azure User Group is missing from DefectDojo, a new User Group will be created automatically.
-
-If you only want to import a subset of Groups from Azure, you can use regex in the Azure AD Groups Filter field.  For example, `'^team-.*'` and `'teamA|teamB|groupC'` are regex strings that can be used to restrict the Groups that will be imported to DefectDojo.  Regex is used to filter out Group Names.
-
-##### Sending Groups from Azure AD
-
-The Azure AD token need to be configured to include Group IDs. Without this step, the token will not contain any notion of a Group, so users will not be mapped correctly in DefectDojo.
-
-To update the format of the token, add a [Group Claim](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-fed-group-claims) that applies to whatever Group type you are using.
-If unsure of what type that is, select `All Groups`. Do not activate `Emit groups as role claims` within the Azure AD "Token configuration" page.
-
-Application API permissions need to be updated with the `GroupMember.Read.All` or `Group.Read.All` permission so that groups can be read on behalf of the user that has successfully signed in.  `GroupMember.Read.All` is recommended as this grants the application fewer permissions.
-
-##### Group Cleaning
-
-If **Enable Azure AD OAuth Group Cleaning** is enabled, groups created by Azure AD in DefectDojo will be automatically removed if they contain no users. Otherwise, Azure-created Groups will be left as-is, even without assigned Users.
-
-When a user is removed from a given group in Azure AD, they will also be removed from the corresponding group in DefectDojo.
-
-### Open-Source
-
-Open-Source users will need to set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-1.  Set the following environment variables
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY=(str, 'YOUR_APPLICATION_ID_FROM_STEP_ABOVE'),
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET=(str, 'YOUR_CLIENT_SECRET_FROM_STEP_ABOVE'),
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID=(str, 'YOUR_DIRECTORY_ID_FROM_STEP_ABOVE'),
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_ENABLED = True
-    {{< /highlight >}}
-
-2.  Restart DefectDojo, and you should now see a **Login with Azure AD** button on the login page.
-
-#### Open-Source Azure Group Mapping
-To import groups from Azure AD users, the following environment variable needs to be set:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS=True
-    {{< /highlight >}}
-
-This will ensure the user is added to all the groups found in the Azure AD Token. Any missing groups will be created in DefectDojo (unless filtered). This group synchronization allows for product access via groups to limit the products a user can interact with.
-
-The Azure AD token returned by Azure will also need to be configured to include group IDs. Without this step, the token will not contain any notion of a group, and the mapping process will report that the current user is not a member of any groups. To update the format of the token, add a group claim that applies to whatever group type you are using.
-
-If unsure of what type that is, select `All Groups`. Do not activate `Emit groups as role claims` within the Azure AD "Token configuration" page.
-
-Application API permissions need to be updated with the `GroupMember.Read.All` or `Group.Read.All` permission so that groups can be read on behalf of the user that has successfully signed in.  `GroupMember.Read.All` is recommended as this grants the application fewer permissions.
-
-To limit the amount of groups imported from Azure AD, a regular expression can be used as the following:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER='^team-.*' # or 'teamA|teamB|groupC'
-    {{< /highlight >}}
-
-##### Automatic Cleanup of User-Groups
-
-To prevent authorization creep, old Azure AD groups a user is not having anymore can be deleted with the following environment parameter:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS=True
-    {{< /highlight >}}
-
-When a user is removed from a given group in Azure AD, they will also be removed from the corresponding group in DefectDojo.
-If there is a group in DefectDojo, that no longer has any members, it will be left as is for record purposes.
-
-## GitHub Enterprise
-
-Both DefectDojo Pro and Open-Source users will need to complete these steps to create an integration:
-
-1.  Navigate to your GitHub Enterprise Server and follow instructions to create a new OAuth App [https://docs.github.com/en/enterprise-server/developers/apps/building-oauth-apps/creating-an-oauth-app](https://docs.github.com/en/enterprise-server/developers/apps/building-oauth-apps/creating-an-oauth-app)
-
-2. Choose a name for your application, e.g. "DefectDojo".
-
-3. For the Redirect URI, enter the DefectDojo URL with the following
-    format
-    -   `https://the_hostname_you_have_dojo_deployed:your_server_port/complete/github-enterprise/`
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select GitHub Enterprise, and use these values from GitHub to complete the form:
-
-    - **GitHub Enterprise OAuth Key**: enter your GitHub Enterprise OAuth App Client ID
-    - **GitHub Enterprise OAuth Secret**: enter your GitHub Enterprise Client Secret
-    - **GitHub Enterprise URL**: enter the GitHub URL for your organization, likely `https://github..com/`
-    - **GitHub Enterprise API URL**: enter the URL for your organization's GitHub API (e.g. `https://github..com/api/v3/`)
-
-2. Check off the box for 'Enable GitHub Enterprise OAuth'.  Submit the form, and 'Login With GitHub' should now be visible on the login page.
-
-### Open-Source
-
-Open-Source users will need to set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-1.  Set the following environment variables
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY=(str, 'GitHub Enterprise OAuth App Client ID'),
-    DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET=(str, 'GitHub Enterprise OAuth App Client Secret'),
-    DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_URL=(str, 'https://github..com/'),
-    DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL=(str, 'https://github..com/api/v3/'),
-    DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_OAUTH2_ENABLED = True,
-    {{< /highlight >}}
-
-2. Restart DefectDojo, and you should now see a **Login with GitHub Enterprise**
-    button on the login page.
-
-## GitLab
-
-In a similar fashion to that of Google and Okta, using GitLab as a
-OAuth2 provider carries the same attributes and a similar procedure.
-Follow along below.
-
-1. Navigate to your GitLab settings page and got to the Applications
-    section
-
-    -   
-    -   **OR**
-    -   **https://the_hostname_you_have_gitlab_deployed:your_gitlab_port/profile/applications**
-
-2. Choose a name for your application, "DefectDojo" for example.
-
-3. For the Redirect URI, enter your DefectDojo URL as follows:
-    -   **https://your-dojo-instance.cloud.defectdojo.com/complete/gitlab/**
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select GitLab, and use these values from GitLab to complete the form:
-
-    - **GitLab OAuth Key**: enter your Application ID from GitLab
-    - **GitLab OAuth Secret**: enter the Secret from GitLab
-    - **GitLab API URL**: enter the URL for your GitLab deployment (e.g. `https://gitlab.com`)
-
-2. Check the 'Enable GitLab OAuth' box, and submit the form. `Login With GitLab` will be added as an option to the Login menu.
-
-### Open-Source
-
-Open-Source users will need to set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-1.  Set the following environment variables
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GITLAB_KEY=(str, 'YOUR_APPLICATION_ID_FROM_STEP_ABOVE'),
-    DD_SOCIAL_AUTH_GITLAB_SECRET=(str, 'YOUR_SECRET_FROM_STEP_ABOVE'),
-    DD_SOCIAL_AUTH_GITLAB_API_URL=(str, 'https://gitlab.com'),
-    DD_SOCIAL_AUTH_GITLAB_OAUTH2_ENABLED = True
-    {{< /highlight >}}
-
-    Additionally, if you want to import your Gitlab projects as DefectDojo
-    products, add the following line to your settings:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GITLAB_PROJECT_AUTO_IMPORT = True
-    {{< /highlight >}}
-
-    **Important:** if you enable this setting on already working instance with a GitLab integrations, it will require new grant "read_repository" by user
-
-2. Restart DefectDojo, and you should now see a **Login with Gitlab** button on the login page.
-
-## Google Auth
-
-Google accounts can be used for user creation and login.
-
-Upon login with a Google account, a new user will be created if one does not already exist.  Existing DefectDojo users will be matched to Google accounts based on their Google username (the name prior to the @ symbol on their Google Account).
-
-In order to use Google Authentication, a Google Authentication Server will need to be set up.  Both DefectDojo Pro and Open-Source users will need to complete these steps to create an integration:
-
-1.  Navigate to the following address and either create a new account,
-    or login with an existing one: [Google Developers
-    Console](https://console.developers.google.com)
-
-2.  Once logged in, find the key shaped button labeled **Credentials**
-    on the left side of the screen. Click **Create Credentials**, and
-    choose **OAuth Client ID**:
-
-    ![image](images/google_1.png)
-
-3.  Select **Web Applications**, and provide a descriptive name for the
-    client (such as "DefectDojo").
-
-4.  Enter `https://your-instance.cloud.defectdojo.com/complete/google-oauth2/` in the **Authorized Redirect URLs** section.
-
-5. Now with the authentication client created, note the **Client ID** and
-   **Client Secret Key**.
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select Google, and use these values to complete the form:
-    - **Google OAuth Key** should be set to your **Client ID**.
-    - **Google OAuth Secret** should be set to your **Client Secret Key**.
-    - **Whitelisted Domains** can be set to the domain name used by your organization.  However, this will allow login from any user with this domain name in their Google email address.
-    - Alternatively, if you only want to allow specific Google email addresses to log in to DefectDojo, you can enter those in the **Whitelisted E-mail Addresses** section of the form. `(appsecuser1@xyz.com,appsecuser2@xyz.com)`, etc.
-    - Note that you must add at least one user or domain to the whitelist, or DefectDojo will not allow any users to log in using Google OAuth.
-
-2. Check the **Enable Google OAuth** box.  Submit the form, and `Login With Google` will be added as an option to the Login menu.
-
-### Open-Source
-
-Open-Source users will need to set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-1.  Set the following environment variables
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED=True,
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=(str, '**YOUR_CLIENT_ID_FROM_STEP_ABOVE**'),
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=(str, '**YOUR_CLIENT_SECRET_FROM_STEP_ABOVE**'),
-    {{< /highlight >}}
-
-   To authorize users you will need to set the following:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = ['example.com', 'example.org']
-    {{< /highlight >}}
-
-    As an environment variable:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = example.com,example.org
-    {{< /highlight >}}
-
-    or
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS = ['']
-    {{< /highlight >}}
-
-    As an environment variable:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS = email@example.com,email2@example.com
-    {{< /highlight >}}
-
-2. Restart DefectDojo, and `Login With Google` will be added as an option to the Login menu.
-
-## KeyCloak
-
-Both DefectDojo Pro and Open-Source users will need to complete these steps to create an integration:
-
-This guide assumes you already have a KeyCloak Realm set up.  If not, you will need to create one: see [KeyCloak Documentation](https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/realms/create.html).
-
-1. Navigate to your keycloak realm and add a new client of type openid-connect. Choose a name for the client id.
-
-2. In the client settings:
-   * Set `access type` to `confidential`
-   * Under `valid Redirect URIs`, add the URI to your DefectDojo installation, e.g.`https://yourorganization.cloud.defectdojo.com` or `https:///*`
-   * Under `web origins`, add the same (or '+')
-   * Under `Fine grained openID connect configuration` -> `user info signed response algorithm`: set to `RS256`
-   * Under `Fine grained openID connect configuration` -> `request object signature algorithm`: set to `RS256`
-   * -> save these settings in keycloak (hit save button)
-
-3. Under `Scope` -> `Full Scope Allowed` set to `off`.
-
-4. Under `mappers` -> add a custom mapper here:
-   * Name: `aud`
-   * Mapper type: `audience`
-   * Included audience: select your client/client-id here
-   * Add ID to token: `off`
-   * Add access to token: `on`
-
-5. Under `credentials`: copy the value of the secret.
-
-6. In your realm settings -> keys: copy the "Public Key" (signing key).
-
-7. In your realm settings -> general -> endpoints: look into openId endpoint configuration and copy the values of your Authorization and Token endpoints.
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select KeyCloak, and use these values to complete the form:
-    - **KeyCloak OAuth Key**: Enter your client name (from step 1)
-    - **KeyCloak OAuth Secret**: Enter the your client credentials secret (from step 5)
-    - **KeyCloak Public Key**: Enter the Public Key from your realm settings (from step 6)
-    - **KeyCloak Resource**: Enter the Authorization Endpoint URL (from step 7)
-    - **KeyCloak Group Limiter**: Enter the Token Endpoint URL (from step 7)
-    - **KeyCloak OAuth Login Button Text** Choose the text you want to use for the DefectDojo login button.
-
-2. Check the 'Enable KeyCloak OAuth' button, and submit the form.  A login button should now be visible on the login page with the text you have set.
-
-### Open-Source
-
-
-Open-Source users will need to set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-1.  Set the following environment variables
-
-{{< highlight python >}}
-   DD_SESSION_COOKIE_SECURE=True,
-   DD_CSRF_COOKIE_SECURE=True,
-   DD_SECURE_SSL_REDIRECT=True,
-   DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED=True,
-   DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY=(str, ''),
-   DD_SOCIAL_AUTH_KEYCLOAK_KEY=(str, ''),
-   DD_SOCIAL_AUTH_KEYCLOAK_SECRET=(str, ''),
-   DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL=(str, ''),
-   DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL=(str, '')
-   {{< /highlight >}}
-
-or, alternatively, for helm configuration, add this to the `extraConfig` section:
-
-```yaml
-DD_SESSION_COOKIE_SECURE: 'True'
-DD_CSRF_COOKIE_SECURE: 'True'
-DD_SECURE_SSL_REDIRECT: 'True'
-DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED: 'True'
-DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY: ''
-DD_SOCIAL_AUTH_KEYCLOAK_KEY: ''
-DD_SOCIAL_AUTH_KEYCLOAK_SECRET: ''
-DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL: ''
-DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL: ''
-```
-
-Optionally, you *can* set `DD_SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT` in order to customize the login button's text caption.
-
-2. Restart DefectDojo, and `Login With ____` (your login button text) will be added as an option to the Login menu.
-
-## Okta
-
-In a similar fashion to that of Google, using Okta as a OAuth2 provider
-carries the same attributes and a similar procedure.
-
-Both DefectDojo Pro and Open-Source users will need to complete these steps to create an integration:
-
-1.  Navigate to the following address and either create a new account,
-    or login with an existing one: [Okta Account
-    Creation](https://www.okta.com/developer/signup/)
-
-2.  Once logged in, enter the **Applications** and click **Add
-    Application**:
-
-    ![image](images/okta_1.png)
-
-3.  Select **Web Applications**.
-
-    ![image](images/okta_2.png)
-
-4.  Add the pictured URLs in the **Login Redirect URLs** section. This
-    part is very important. If there are any mistakes here, the
-    authentication client will not authorize the request, and deny
-    access. Check the **Implicit** box as well.
-
-    ![image](images/okta_3.png)
-
-5.  Once all URLs are added, finish by clicking **Done**.
-
-6.  Return to the **Dashboard** to find the **Org-URL**. Note this value
-    as it will be important when configuring DefectDojo.
-
-    ![image](images/okta_4.png)
-
-7.  Now, with the authentication client created, the **Client ID** and
-    **Client Secret** Key need to be copied over to the settings.
-    Click the newly created client and copy the values:
-
-    ![image](images/okta_5.png)
-
-### Pro Configuration
-
-DefectDojo Pro users can set up this integration from the OAuth Settings page, which is nested under **Enterprise Settings**.
-
-1. In DefectDojo's OAuth Settings page, select Okta, and use these values to complete the form:
-    - **Okta OAuth Key**: set this to your Client ID from step 7 above.
-    - **Okta OAuth Secret**: set this to your Client Secret from step 7 above.
-    - **Okta Tenant ID**: set this to your Okta Org-URL: `https://{your-org-url}/oauth2` for example
-    -
-
-2. Check the 'Enable Okta OAuth' button, and submit the form.  A 'Login With Okta' button should now be visible on the DefectDojo login screen.
-
-### Open-Source
-
-Open-Source users will need to set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-1.  Set the following environment variables
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_OKTA_OAUTH2_ENABLED=True,
-    DD_SOCIAL_AUTH_OKTA_OAUTH2_KEY=(str, '**YOUR_CLIENT_ID_FROM_STEP_ABOVE**'),
-    DD_SOCIAL_AUTH_OKTA_OAUTH2_SECRET=(str, '**YOUR_CLIENT_SECRET_FROM_STEP_ABOVE**'),
-    DD_SOCIAL_AUTH_OKTA_OAUTH2_API_URL=(str, 'https://{your-org-url}/oauth2'),
-    {{< /highlight >}}
-
-If during the login process you get the following error: *The
-'redirect_uri' parameter must be an absolute URI that is whitelisted
-in the client app settings.* and the `redirect_uri` HTTP
-GET parameter starts with `http://` instead of
-`https://` you need to add
-`DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS = True` to Docker Compose environment variables, or `SOCIAL_AUTH_REDIRECT_IS_HTTPS` to your `local_settings.py` file.
-
-2. Restart DefectDojo, and 'Login With Okta' should appear on the login screen.
-
-## OIDC (OpenID Connect)
-
-Adding OIDC gives you the option to authenticate users using a generic OIDC provider.
-
-### Pro Configuration
-
-In DefectDojo Pro, OIDC can be configured from the OIDC settings page:
-
-![image](images/oidc_pro.png)
-
-Fill out the form as follows
-
-1. Enter your OIDC endpoint in the Endpoint field.  This is the base URL of your OIDC instance (you do not need to include `/.well-known/open-id-configuration/`)
-
-2. Enter your OIDC Client ID in the Client ID field.
-
-3. Enter the OIDC Client Secret in the Client Secret field.
-
-4. Check the box for Enable OIDC.
-
-Once the form has been submitted, Log In With OIDC should be added as an option to the DefectDojo login page.
-
-
-### Open-Source
-
-The minimum configuration requires you to set the following environment variables:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED=True,
-    DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT=(str, 'https://example.com'),
-    DD_SOCIAL_AUTH_OIDC_KEY=(str, 'YOUR_CLIENT_ID'),
-    DD_SOCIAL_AUTH_OIDC_SECRET=(str, 'YOUR_CLIENT_SECRET')
-    {{< /highlight >}}
-
-The rest of the OIDC configuration will be auto-detected by fetching data from:
- - /.well-known/open-id-configuration/
-
-You can also optionally set the following variables:
-
-    {{< highlight python >}}
-    DD_SOCIAL_AUTH_OIDC_ID_KEY=(str, ''),                           #the key associated with the OIDC user IDs
-    DD_SOCIAL_AUTH_OIDC_USERNAME_KEY=(str, ''),                     #the key associated with the OIDC usernames
-    DD_SOCIAL_AUTH_CREATE_USER_MAPPING=(str, "username"),           #could also be email or fullname
-    DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS=(list, ['']),           #list of domains allowed for login
-    DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS=(list, ["RS256","HS256"]),
-    DD_SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER=(str, ''),
-    DD_SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL=(str, ''),
-    DD_SOCIAL_AUTH_OIDC_AUTHORIZATION_URL=(str, ''),
-    DD_SOCIAL_AUTH_OIDC_USERINFO_URL=(str, ''),
-    DD_SOCIAL_AUTH_OIDC_JWKS_URI=(str, ''),
-    DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT=(str, "Login with OIDC"),
-    {{< /highlight >}}
-
-Once these variables have been set, restart DefectDojo. Log In With OIDC should now be added to the DefectDojo login page.
-
-## SAML Configuration
-
-DefectDojo Pro users can follow this guide to set up a SAML configuration using the DefectDojo UI. Open-Source users can set up SAML via environment variables, using the following [guide](./#open-source-saml).
-
-1. Open the SAML Settings page to view the SAML form.  This page is located under the **Enterprise Settings** option on the sidebar.
-
-![image](images/sso_betaui_1.png)
-
-2. Complete the SAML form. Start by setting an **Entity ID** \- this is either a label or a URL which your SAML Identity Provider can point to, and use to identify DefectDojo. This is a required field.
-​
-3. If you wish, set **Login Button Text** in DefectDojo. This text will appear on the button or link users click to initiate the login process.
-​
-4. You can also set a **Logout URL** to redirect your users to once they have logged out of DefectDojo.
-​
-5. The **Name ID Format** has four options: Persistent, Transient, Entity and Encrypted.
-​
-    - If you would prefer that users have a different SAML ID each time they access
-    DefectDojo, choose **Transient**.
-    - If you want your users to be consistently identified by SAML, use **Persistent.**
-    - If you’re ok with all of your users sharing a SAML NameID, you can select **Entity.**
-    - If you would like to encrypt each user’s NameID, you can use **Encrypted** as your NameID format.
-​
-6. **Required Attributes** are the attributes that DefectDojo requires from the SAML response.
-​
-7. **Attribute Mapping** contains a formula for how you want these attributes to be matched to a user. For example, if your SAML response returns an email, you can associate it with a DefectDojo user with the formula **email=email**.
-​
-The left side of the ‘=’ sign represents the attribute you want to map from the SAML response. The right side is a user’s field in DefectDojo, which you want this attribute to map to.
-​
-8. **Remote SAML Metadata** is the URL where your SAML Identity Provider is located.
-​
-9. Finally, check the **Enable SAML** checkbox at the bottom of this form to confirm that you want to use SAML to log in. Once this is enabled, you will see the **Login With SAML** button on the DefectDojo Login Page.
-
-![image](images/sso_saml_login.png)
-
-#### Additional SAML Options
-
-* **Create Unknown User** allows you to decide whether or not to automatically create a new user in DefectDojo if they aren’t found in the SAML response.
-
-* **Allow Unknown Attributes** allows you to authorize users who have attributes which are not found in the **Attribute Mapping** field.
-
-* **Sign Assertions/Responses** will require any incoming SAML responses to be signed.
-
-* **Sign Logout Requests** forces DefectDojo to sign any logout requests.
-
-* **Force Authentication** determines whether you want to force your users to authenticate using your Identity Provider each time, regardless of existing sessions.
-
-* **Enable SAML Debugging** will log more detailed SAML output for debugging purposes.
-
-#### SAML Group Mapping
-
-DefectDojo can use the SAML assertion to automatically assign users to [User Groups](../create_user_group/).  Groups in DefectDojo can assign Permissions to all of their Group members, so using Group Mapping allows you to assign those permissions in bulk.  This is the only way to set permissions via SAML.
-
-The **Group Name Attribute** field specifies which attribute in the SAML assertion contains the user's group memberships. When a user logs in, DefectDojo reads this attribute and assigns the user to any matching groups. To limit which groups from the assertion are considered, use the **Group Limiter Regex Expression** field.
-
-If no Group with a matching name exists, DefectDojo will automatically create one. Note that this Group will not have any permissions at the time of creation, but those can be configured later by a DefectDojo user with appropriate permissions.
-
-To activate group mapping, check the **Enable Group Mapping** checkbox at the bottom of the form.
-
-### Open-Source SAML
-
-1.  Navigate to your SAML IdP and find your metadata.
-2.  Set these variables as an environment variable, or without the `DD_` prefix in the `local_settings.py` file. (see [Configuration](/get_started/open_source/configuration)).
-
-    {{< highlight python >}}
-    DD_SAML2_ENABLED=(bool, **True**),
-    # SAML Login Button Text
-    DD_SAML2_LOGIN_BUTTON_TEXT=(str, 'Login with SAML'),
-    # If the metadata can be accessed from a url, try the
-    DD_SAML2_METADATA_AUTO_CONF_URL=(str, ''),
-    # Otherwise, downlaod a copy of the metadata into an xml file, and
-    # list the path in DD_SAML2_METADATA_LOCAL_FILE_PATH
-    DD_SAML2_METADATA_LOCAL_FILE_PATH=(str, '/path/to/your/metadata.xml'),
-    # Fill in DD_SAML2_ATTRIBUTES_MAP to corresponding SAML2 userprofile attributes provided by your IdP
-    DD_SAML2_ATTRIBUTES_MAP=(dict, {
-        # format: SAML attrib:django_user_model
-        'Email': 'email',
-        'UserName': 'username',
-        'Firstname': 'first_name',
-        'Lastname': 'last_name'
-    }),
-    # May configure the optional fields
-    {{< /highlight >}}
-
-NOTE: *DD_SAML2_ATTRIBUTES_MAP* in k8s can be referenced as extraConfig (e.g. `DD_SAML2_ATTRIBUTES_MAP: 'Email'='email', 'Username'='username'...`)
-
-NOTE: *DD_SITE_URL* might also need to be set depending on the choices you make with the metadata.xml provider. (File versus URL).
-
-4.  Checkout the SAML section in dojo/`dojo/settings/settings.dist.py` and verfiy if it fits your requirement. If you need help, take a look at the [plugin documentation](https://djangosaml2.readthedocs.io/contents/setup.html#configuration).
-
-5.  Restart DefectDojo, and you should now see a **Login with SAML** button (default setting of DD_SAML2_LOGIN_BUTTON_TEXT) on the login page.
-
-NOTE: In the case when IDP is configured to use self signed (private) certificate,
-than CA needs to be specified by define environments variable
-REQUESTS_CA_BUNDLE that points to the path of private CA certificate.
-
-#### Troubleshooting
-
-The SAML Tracer browser add-on can help troubleshoot SAML problems: [Chrome](https://chromewebstore.google.com/detail/saml-tracer/mpdajninpobndbfcldcmbpnnbhibjmch?hl=en), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/).
-
-#### Advanced Configuration
-The [djangosaml2](https://github.com/IdentityPython/djangosaml2) plugin has a lot of options. For details take a look at the [plugin documentation](https://djangosaml2.readthedocs.io/contents/setup.html#configuration).
-
-All default options in DefectDojo can overwritten in the local_settings.py file. If you want to change the organization name, you can add the following lines:
-
-{{< highlight python >}}
-if SAML2_ENABLED:
-    SAML_CONFIG['contact_person'] = [{
-        'given_name': 'Extra',
-        'sur_name': 'Example',
-        'company': 'DefectDojo',
-        'email_address': 'dummy@defectdojo.com',
-        'contact_type': 'technical'
-    }]
-    SAML_CONFIG['organization'] = {
-        'name': [('DefectDojo', 'en')],
-        'display_name': [('DefectDojo', 'en')],
-    },
-{{< /highlight >}}
-
-![image](images/sso_oauth_beta_ui.png)
-
-#### Migration from django-saml2-auth
-Up to relase 1.15.0 the SAML integration was based on [django-saml2-auth](https://github.com/fangli/django-saml2-auth). Which the switch to djangosaml2 some parameters has changed:
-
-* DD_SAML2_ASSERTION_URL: not necessary any more - automatically generated
-* DD_SAML2_DEFAULT_NEXT_URL: not necessary any more - default forwarding from defectdojo is used
-* DD_SAML2_NEW_USER_PROFILE: not possible any more - default profile is used, see User Permissions
-* DD_SAML2_ATTRIBUTES_MAP: Syntax has changed
-* DD_SAML2_CREATE_USER: Default value changed to False, to avoid security breaches
-
-## Other Open-Source Options
-
-### RemoteUser
-
-This implementation is suitable if the DefectDojo instance is placed behind HTTP Authentication Proxy.
-Dojo expects that the proxy will perform authentication and pass HTTP requests to the Dojo instance with filled HTTP headers.
-The proxy should check if an attacker is not trying to add a malicious HTTP header and bypass authentication.
-
-Values which need to be set:
-
-* `DD_AUTH_REMOTEUSER_ENABLED` - Needs to be set to `True`
-* `DD_AUTH_REMOTEUSER_USERNAME_HEADER` - Name of the header which contains the username
-* `DD_AUTH_REMOTEUSER_EMAIL_HEADER`(optional) - Name of the header which contains the email
-* `DD_AUTH_REMOTEUSER_FIRSTNAME_HEADER`(optional) - Name of the header which contains the first name
-* `DD_AUTH_REMOTEUSER_LASTNAME_HEADER`(optional) - Name of the header which contains the last name
-* `DD_AUTH_REMOTEUSER_GROUPS_HEADER`(optional) - Name of the header which contains the comma-separated list of groups; user will be assigned to these groups (missing groups will be created)
-* `DD_AUTH_REMOTEUSER_GROUPS_CLEANUP`(optional) - Same as [#automatic-import-of-user-groups](AzureAD implementation)
-* `DD_AUTH_REMOTEUSER_TRUSTED_PROXY` - Comma separated list of proxies; Simple IP and CIDR formats are supported
-* `DD_AUTH_REMOTEUSER_LOGIN_ONLY`(optional) - Check [Django documentation](https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#using-remote-user-on-login-pages-only)
-
-*WARNING:* There is possible spoofing of headers (for all `DD_AUTH_REMOTEUSER_xxx_HEADER` values). Read Warning in [Django documentation](https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#configuration)
-
-### User Permissions
-
-When a new user is created via the social-auth, only the default permissions are active. This means that the newly created user does not have access to add, edit, nor delete anything within DefectDojo. There are two parameters in the System Settings to influence the permissions for newly created users:
-
-#### Default group
-
-When both the parameters `Default group` and `Default group role` are set, the new user will be a member of the given group with the given role, which will give him the respective permissions.
-
-#### Groups from Identity Providers
-
-Some Identity Providers are able to send list of groups to which should user belongs. This functionality is implemented only for Identity Providers mentioned below. For all others, we will be more than happy for contribution (hint: functions `assign_user_to_groups` and `cleanup_old_groups_for_user` from [`dojo/pipeline.py`](https://github.com/DefectDojo/django-DefectDojo/blob/master/dojo/pipeline.py) might be useful).
-
-- [Azure](#open-source-azure-group-mapping): Check `DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS` and `DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS`
-- [RemoteUser](#remoteuser): Check `DD_AUTH_REMOTEUSER_GROUPS_HEADER` and `DD_AUTH_REMOTEUSER_GROUPS_CLEANUP`
-
-### Other Providers
-
-In an effort to accommodate as much generality as possible, it was
-decided to implement OAuth2 with the
-[social-auth](https://github.com/python-social-auth/social-core/tree/master/social_core/backends)
-ecosystem as it has a library of compatible providers with documentation
-of implementation. Conveniently, each provider has an identical
-procedure of managing the authenticated responses and authorizing access
-within a given application. The only difficulty is creating a new
-authentication client with a given OAuth2 provider.
diff --git a/docs/content/admin/user_management/create_user_group.md b/docs/content/admin/user_management/create_user_group.md
index 1a4f9ed7305..d432470212a 100644
--- a/docs/content/admin/user_management/create_user_group.md
+++ b/docs/content/admin/user_management/create_user_group.md
@@ -1,10 +1,14 @@
 ---
 title: "Share permissions: User Groups"
-description: "Share and maintain permissions for many users"
+description: "Share and maintain permissions for many users in DefectDojo Pro"
 weight: 3
+audience: pro
 aliases:
   - /en/customize_dojo/user_management/create_user_group
 ---
+
+> **DefectDojo Pro feature.** User Groups and the underlying RBAC system are part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions.
+
 If you have a significant number of DefectDojo users, you may want to create one or more **Groups**, in order to set the same Role\-Based Access Control (RBAC) rules for many users simultaneously. Only Superusers can create User Groups.
 
 Groups can work in multiple ways:
diff --git a/docs/content/admin/user_management/set_user_permissions.md b/docs/content/admin/user_management/set_user_permissions.md
index e828a0a9e92..ed2018423fc 100644
--- a/docs/content/admin/user_management/set_user_permissions.md
+++ b/docs/content/admin/user_management/set_user_permissions.md
@@ -2,10 +2,13 @@
 title: "Set a User's permissions"
 description: "How to grant Roles & Permissions to a user, as well as superuser status"
 weight: 2
-audience: opensource
+audience: pro
 aliases:
   - /en/customize_dojo/user_management/set_user_permissions
 ---
+
+> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions.
+
 ## Introduction to Permission Types
 
 Individual users have four different kinds of permission that they can be assigned:
diff --git a/docs/content/admin/user_management/user_permission_chart.md b/docs/content/admin/user_management/user_permission_chart.md
index 07c4d343ce9..e29c65b0189 100644
--- a/docs/content/admin/user_management/user_permission_chart.md
+++ b/docs/content/admin/user_management/user_permission_chart.md
@@ -1,10 +1,14 @@
 ---
 title: "Action permission charts"
-description: "All user permissions in detail"
+description: "All DefectDojo Pro user permissions in detail"
 weight: 4
+audience: pro
 aliases:
   - /en/customize_dojo/user_management/user_permission_chart
 ---
+
+> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions.
+
 ## Role Permission Chart
 
 This chart is intended to list all permissions related to a Product or Product Type, as well as which permissions are available to each role.
diff --git a/docs/content/automation/api/api-v2-docs.md b/docs/content/automation/api/api-v2-docs.md
index 69cec007107..82fb8730765 100644
--- a/docs/content/automation/api/api-v2-docs.md
+++ b/docs/content/automation/api/api-v2-docs.md
@@ -43,7 +43,7 @@ For example: :
 
 ### Alternative authentication method
 
-If you use [an alternative authentication method](en/customize_dojo/user_management/configure_sso/ for users, you may want to disable DefectDojo API tokens because it could bypass your authentication concept. \
+If you use [an alternative authentication method](/admin/sso/) for users, you may want to disable DefectDojo API tokens because it could bypass your authentication concept. \
 Using of DefectDojo API tokens can be disabled by specifying the environment variable `DD_API_TOKENS_ENABLED` to `False`.
 Or only `api/v2/api-token-auth/` endpoint can be disabled by setting `DD_API_TOKEN_AUTH_ENDPOINT_ENABLED` to `False`.
 
diff --git a/docs/content/releases/os_upgrading/2.59.md b/docs/content/releases/os_upgrading/2.59.md
index c9921cf6be8..0ce83f4a1bc 100644
--- a/docs/content/releases/os_upgrading/2.59.md
+++ b/docs/content/releases/os_upgrading/2.59.md
@@ -2,9 +2,57 @@
 title: 'Upgrading to DefectDojo Version 2.59.x'
 toc_hide: true
 weight: -20260602
-description: Removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings
+description: Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings
 ---
 
+## Authorized Users panel replaces Members/Groups under legacy authorization
+
+Open Source DefectDojo uses the legacy authorization model: access to a Product is granted by `Product.authorized_users` (with cascade via `Product_Type.authorized_users`), and `is_staff` / `is_superuser` bypass everything.
+
+In 2.59 the classic UI restores the **"Authorized Users"** panel on the Product and Product Type detail pages. The panel reads from and writes to `Product.authorized_users` / `Product_Type.authorized_users` directly, so adding a user actually grants them the access the UI suggests it does.
+
+### New endpoints
+
+- `GET/POST /product//authorized_users/add` — list / add users to `Product.authorized_users`
+- `POST /product//authorized_users//delete` — remove a user
+- `GET/POST /product/type//authorized_users/add` — same for `Product_Type.authorized_users`
+- `POST /product/type//authorized_users//delete`
+
+Both endpoints are gated so only `is_staff` / `is_superuser` users can add or remove. Non-staff users see the panel but no management actions.
+
+### How RBAC rows are converted
+
+The data migration `0267_backfill_authorized_users` translates RBAC tables into the legacy model with the following rules:
+
+| RBAC row | Legacy effect |
+|---|---|
+| `Product_Member` (any role, direct or via `Product_Group` + `Dojo_Group_Member`) | Adds the user to `Product.authorized_users` |
+| `Product_Type_Member` (any role, direct or via `Product_Type_Group` + `Dojo_Group_Member`) | Adds the user to `Product_Type.authorized_users` |
+| `Global_Role(Owner)` (direct or via group) | Sets `User.is_superuser = True` |
+| `Global_Role(Writer | Maintainer | API_Importer)` (direct or via group) | Sets `User.is_staff = True` |
+| `Global_Role(Reader)` | No global elevation — relies on per-product membership |
+
+Per-product role granularity (Reader vs Writer vs Maintainer vs Owner) collapses to membership-only because the legacy model has no per-product role concept. `Dojo_Group` structure as a permission-bearing entity is also lost; only the flattened individual user memberships remain.
+
+### Required actions
+
+- **Database migrations run automatically on upgrade.** Existing access is carried forward into the legacy `authorized_users` model. Existing data is preserved.
+- **Audit the upgrade in staging first.** A new `python manage.py preview_legacy_authorization_migration` management command is shipped in 2.59 to summarize what an upgrade would change against a given database. It is read-only. Recommended workflow: install 2.59 in a staging environment with a snapshot of your production database, run the command, review the summary, then upgrade production.
+- **Migrating from OS to Pro?** A new `python manage.py reconcile_authorized_users_to_rbac` management command is available on Pro to bring any access changes you made under OS forward into Pro RBAC. It supports `--dry-run` and is idempotent.
+
+### Pro customers are not impacted
+
+DefectDojo Pro deployments retain full RBAC. The Pro UX is unchanged — same Members/Groups management surface as before.
+
+## SSO providers are available in DefectDojo Pro only
+
+Single sign-on (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, and remote-user header authentication) has been consolidated into DefectDojo Pro. Open source DefectDojo now exposes only local username/password login and the password-reset flow.
+
+### Required actions
+
+- **No customizations or local-only login:** No action required.
+- **Currently logging in via SSO on open source:** Existing user accounts and group memberships are preserved on upgrade, but SSO sign-in will no longer work after 2.59. To keep an SSO-driven login experience, switch to [DefectDojo Pro](https://defectdojo.com), which carries forward and extends the SSO surface (provider configuration moves to a UI-managed tuner).
+
 ## Removal: Questionnaire API Endpoints
 
 As announced in DefectDojo 2.56.0, the following Questionnaire API endpoints have been removed:
diff --git a/docs/content/releases/pro/changelog.md b/docs/content/releases/pro/changelog.md
index 4c170941c3f..a172e11f1be 100644
--- a/docs/content/releases/pro/changelog.md
+++ b/docs/content/releases/pro/changelog.md
@@ -10,6 +10,13 @@ Here are the release notes for **DefectDojo Pro (Cloud Version)**. These release
 
 For Open Source release notes, please see the [Releases page on GitHub](https://github.com/DefectDojo/django-DefectDojo/releases), or alternatively consult the Open Source [upgrade notes](/releases/os_upgrading/upgrading_guide/).
 
+## June 2026: v2.59
+
+### June 1, 2026: v2.59.0
+
+* **(Authorization)** Pro deployments are **not impacted** by the OS legacy authorization rewrite. Pro retains full RBAC: the Members / Groups panels on Product and Product Type detail, the Groups panel + Global Role fieldset on the user view / profile / add user pages, the Group Members panel on the user view, the Groups link in the left-nav, and the System Settings default-group fields all continue to render unchanged, driven by Pro RBAC via template overrides at `pro/templates/dojo/`. The eight RBAC v2 API endpoints (`/api/v2/dojo_groups/`, `/api/v2/dojo_group_members/`, `/api/v2/global_roles/`, `/api/v2/product_groups/`, `/api/v2/product_members/`, `/api/v2/product_type_groups/`, `/api/v2/product_type_members/`, `/api/v2/roles/`) are re-registered by Pro's `add_*_urls` hooks. Pro's runtime authorization shadowing in `pro/apps.py:DojoProConfig.ready()` continues to govern object, global, and configuration permissions, so the OS-side `is_staff` bypass for configuration permissions does not affect Pro semantics.
+* **(SSO)** SSO providers (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, and remote-user header auth) are **Pro-only** as of 2.59. The implementation that previously shipped in open source (`dojo/sso/`) was consolidated into Pro at `pro/sso/`, and the social-auth and djangosaml2 dependencies moved to Pro's package. Pro deployments continue to expose the full SSO surface — login buttons, the tuner-driven runtime configuration, and the `remove_sso` management command — unchanged. Open source customers using SSO need to migrate to Pro to retain SSO sign-in.
+
 ## May 2026: v2.58
 
 ### May 6, 2026: v2.58.1
diff --git a/dojo/announcement/views.py b/dojo/announcement/views.py
index 26160c3236b..7afe915210b 100644
--- a/dojo/announcement/views.py
+++ b/dojo/announcement/views.py
@@ -7,9 +7,6 @@
 from django.utils.translation import gettext
 from django.utils.translation import gettext_lazy as _
 
-from dojo.authorization.authorization_decorators import (
-    user_is_configuration_authorized,
-)
 from dojo.forms import AnnouncementCreateForm, AnnouncementRemoveForm
 from dojo.models import Announcement, UserAnnouncement
 from dojo.utils import add_breadcrumb
@@ -17,7 +14,6 @@
 logger = logging.getLogger(__name__)
 
 
-@user_is_configuration_authorized("dojo.change_announcement")
 def configure_announcement(request):
     remove = False
     if request.method == "GET":
diff --git a/dojo/api_v2/permissions.py b/dojo/api_v2/permissions.py
index e0afe49f1a5..aca3f6aea55 100644
--- a/dojo/api_v2/permissions.py
+++ b/dojo/api_v2/permissions.py
@@ -1,1271 +1,9 @@
-
-from django.conf import settings
-from django.db.models import Model
-from django.shortcuts import get_object_or_404
-from rest_framework import permissions, serializers
-from rest_framework.exceptions import (
-    ParseError,
-    PermissionDenied,
-    ValidationError,
-)
-from rest_framework.request import Request
-
-from dojo.authorization.authorization import (
-    user_has_configuration_permission,
-    user_has_global_permission,
-    user_has_permission,
-    user_is_superuser_or_global_owner,
-)
-from dojo.authorization.roles_permissions import Permissions
-from dojo.importers.auto_create_context import AutoCreateContextManager
-from dojo.location.models import Location
-from dojo.models import (
-    Development_Environment,
-    Dojo_Group,
-    Endpoint,
-    Engagement,
-    Finding,
-    Finding_Group,
-    Product,
-    Product_Type,
-    Regulation,
-    SLA_Configuration,
-    Test,
-)
-
-
-def check_post_permission(request: Request, post_model: Model, post_pk: str | list[str], post_permission: int) -> bool:
-    if request.method == "POST":
-        if request.data.get(post_pk) is None:
-            msg = f"Unable to check for permissions: Attribute '{post_pk}' is required"
-            raise ParseError(msg)
-        obj = get_object_or_404(post_model, pk=request.data.get(post_pk))
-        return user_has_permission(request.user, obj, post_permission)
-    return True
-
-
-def check_object_permission(
-    request: Request,
-    obj: Model,
-    get_permission: int,
-    put_permission: int,
-    delete_permission: int,
-    post_permission: int | None = None,
-) -> bool:
-    if request.method == "GET":
-        return user_has_permission(request.user, obj, get_permission)
-    if request.method in {"PUT", "PATCH"}:
-        return user_has_permission(request.user, obj, put_permission)
-    if request.method == "DELETE":
-        return user_has_permission(request.user, obj, delete_permission)
-    if request.method == "POST":
-        return user_has_permission(request.user, obj, post_permission)
-    return False
-
-
-class BaseRelatedObjectPermission(permissions.BasePermission):
-
-    """
-    An "abstract" base class for related object permissions (like notes, metadata, etc.)
-    that only need object permissions, not general permissions. This class will serve as
-    the base class for other more aptly named permission classes.
-    """
-
-    permission_map: dict[str, int] = {
-        "get_permission": None,
-        "put_permission": None,
-        "delete_permission": None,
-        "post_permission": None,
-    }
-
-    def has_permission(self, request: Request, view):
-        # related object only need object permission
-        return True
-
-    def has_object_permission(self, request: Request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            **self.permission_map,
-        )
-
-
-class BaseDjangoModelPermission(permissions.BasePermission):
-
-    """
-    An "abstract" base class for Django model permissions.
-    This class will serve as the base class for other more aptly named permission classes.
-    """
-
-    django_model: Model = None
-    request_method_permission_map: dict[str, str] = {
-        "GET": "view",
-        "POST": "add",
-        "PUT": "change",
-        "PATCH": "change",
-        "DELETE": "delete",
-    }
-
-    def _evaluate_permissions(self, request: Request, permissions: dict[str, str]) -> bool:
-        # Short circuit if the request method is not in the expected methods
-        if request.method not in permissions:
-            return True
-        # Evaluate the permissions as usual
-        for method, permission in permissions.items():
-            if request.method == method:
-                return user_has_configuration_permission(
-                    request.user,
-                    f"{self.django_model._meta.app_label}.{permission}_{self.django_model._meta.model_name}",
-                )
-        return False
-
-    def has_permission(self, request: Request, view):
-        # First restrict the mapping got GET/POST only
-        expected_request_method_permission_map = {k: v for k, v in self.request_method_permission_map.items() if k in {"GET", "POST"}}
-        # Evaluate the permissions
-        return self._evaluate_permissions(request, expected_request_method_permission_map)
-
-    def has_object_permission(self, request: Request, view, obj):
-        return self._evaluate_permissions(request, self.request_method_permission_map)
-
-
-class UserHasAppAnalysisPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Technology_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj.product,
-            Permissions.Technology_View,
-            Permissions.Technology_Edit,
-            Permissions.Technology_Delete,
-        )
-
-
-class UserHasDojoGroupPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        if request.method == "GET":
-            return user_has_configuration_permission(
-                request.user, "auth.view_group",
-            )
-        if request.method == "POST":
-            return user_has_configuration_permission(
-                request.user, "auth.add_group",
-            )
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        if request.method == "GET":
-            # Users need to be authorized to view groups in general and only the groups they are a member of
-            # because with the group they can see user information that might
-            # be considered as confidential
-            return user_has_configuration_permission(
-                request.user, "auth.view_group",
-            ) and user_has_permission(
-                request.user, obj, Permissions.Group_View,
-            )
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Group_View,
-            Permissions.Group_Edit,
-            Permissions.Group_Delete,
-        )
-
-
-class UserHasDojoGroupMemberPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Dojo_Group, "group", Permissions.Group_Manage_Members,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Group_View,
-            Permissions.Group_Manage_Members,
-            Permissions.Group_Member_Delete,
-        )
-
-
-class UserHasDojoMetaPermission(permissions.BasePermission):
-    permission_map = {
-        "product": {
-            "model": Product,
-            "permissions": {
-                "get_permission": Permissions.Product_View,
-                "put_permission": Permissions.Product_Edit,
-                "delete_permission": Permissions.Product_Edit,
-                "post_permission": Permissions.Product_Edit,
-            },
-        },
-        "finding": {
-            "model": Finding,
-            "permissions": {
-                "get_permission": Permissions.Finding_View,
-                "put_permission": Permissions.Finding_Edit,
-                "delete_permission": Permissions.Finding_Edit,
-                "post_permission": Permissions.Finding_Edit,
-            },
-        },
-        "location": {
-            "model": Location,
-            "permissions": {
-                "get_permission": Permissions.Location_View,
-                "put_permission": Permissions.Location_Edit,
-                "delete_permission": Permissions.Location_Edit,
-                "post_permission": Permissions.Location_Edit,
-            },
-        },
-        # TODO: Delete this after the move to Locations
-        "endpoint": {
-            "model": Endpoint if not settings.V3_FEATURE_LOCATIONS else Location,
-            "permissions": {
-                "get_permission": Permissions.Location_View,
-                "put_permission": Permissions.Location_Edit,
-                "delete_permission": Permissions.Location_Edit,
-                "post_permission": Permissions.Location_Edit,
-            },
-        },
-    }
-
-    def has_permission(self, request, view):
-        method_to_permission_map = {
-            "GET": "get_permission",
-            "POST": "post_permission",
-            # PATCH is generally not used here, but this endpoint is sorta odd...
-            "PATCH": "put_permission",
-        }
-        for request_method, permission_type in method_to_permission_map.items():
-            if request.method == request_method:
-                has_permission_result = True
-                for model_field, schema in self.permission_map.items():
-                    if (object_id := request.data.get(model_field)) is not None:
-                        obj = get_object_or_404(
-                            schema["model"],
-                            pk=object_id,
-                        )
-                        has_permission_result = (
-                            has_permission_result
-                            and user_has_permission(
-                                request.user,
-                                obj,
-                                schema["permissions"][permission_type],
-                            )
-                        )
-                return has_permission_result
-        # If we exit the loop at some point, we must not checking perms for that request method
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        has_permission_result = True
-        for model_field, schema in self.permission_map.items():
-            if (object_model := getattr(obj, model_field, None)) is not None:
-                has_permission_result = (
-                has_permission_result
-                and check_object_permission(
-                    request,
-                    object_model,
-                    **schema["permissions"],
-                )
-            )
-
-        return has_permission_result
-
-
-class UserHasToolProductSettingsPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Product_Edit,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj.product,
-            Permissions.Product_View,
-            Permissions.Product_Edit,
-            Permissions.Product_Edit,
-        )
-
-
-# TODO: Delete this after the move to Locations
-class UserHasEndpointPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Location_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Location_View,
-            Permissions.Location_Edit,
-            Permissions.Location_Delete,
-        )
-
-
-# TODO: Delete this after the move to Locations
-class UserHasEndpointStatusPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Endpoint, "endpoint", Permissions.Location_Edit,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj.endpoint,
-            Permissions.Location_View,
-            Permissions.Location_Edit,
-            Permissions.Location_Edit,
-        )
-
-
-class UserHasEngagementPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-                request, Product, "product", Permissions.Engagement_Add,
-            )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Engagement_View,
-            Permissions.Engagement_Edit,
-            Permissions.Engagement_Delete,
-        )
-
-
-class UserHasEngagementRelatedObjectPermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Engagement_View,
-        "put_permission": Permissions.Engagement_Edit,
-        "delete_permission": Permissions.Engagement_Edit,
-        "post_permission": Permissions.Engagement_Edit,
-    }
-
-
-class UserHasEngagementNotePermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Engagement_View,
-        "put_permission": Permissions.Engagement_Edit,
-        "delete_permission": Permissions.Engagement_Edit,
-        "post_permission": Permissions.Engagement_View,
-    }
-
-
-class UserHasRiskAcceptancePermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        # The previous implementation only checked for the object permission if the path was
-        # /api/v2/risk_acceptances/, but the path has always been /api/v2/risk_acceptance/ (notice the missing "s")
-        # So there really has not been a notion of a post permission check for risk acceptances.
-        # It would be best to leave as is to not break any existing implementations.
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Risk_Acceptance,
-            Permissions.Risk_Acceptance,
-            Permissions.Risk_Acceptance,
-        )
-
-
-class UserHasRiskAcceptanceRelatedObjectPermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Risk_Acceptance,
-        "put_permission": Permissions.Risk_Acceptance,
-        "delete_permission": Permissions.Risk_Acceptance,
-        "post_permission": Permissions.Risk_Acceptance,
-    }
-
-
-class UserHasFindingPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Test, "test", Permissions.Finding_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Finding_View,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Delete,
-        )
-
-
-class UserHasFindingRelatedObjectPermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Finding_View,
-        "put_permission": Permissions.Finding_Edit,
-        "delete_permission": Permissions.Finding_Edit,
-        "post_permission": Permissions.Finding_Edit,
-    }
-
-
-class UserHasFindingNotePermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Finding_View,
-        "put_permission": Permissions.Finding_Edit,
-        "delete_permission": Permissions.Finding_Edit,
-        "post_permission": Permissions.Finding_View,
-    }
-
-
-class UserHasBurpRawRequestResponsePermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Finding, "finding", Permissions.Finding_Edit,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj.finding,
-            Permissions.Finding_View,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Edit,
-        )
-
-
-class UserHasImportPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        # permission check takes place before validation, so we don't have access to serializer.validated_data()
-        # and we have to validate ourselves unfortunately
-        auto_create = AutoCreateContextManager()
-        # Process the context to make an conversions needed. Catch any exceptions
-        # in this case and wrap them in a DRF exception
-        try:
-            converted_dict = auto_create.convert_querydict_to_dict(request.data)
-            auto_create.process_import_meta_data_from_dict(converted_dict)
-            # Get an existing product
-            converted_dict["product_type"] = auto_create.get_target_product_type_if_exists(**converted_dict)
-            converted_dict["product"] = auto_create.get_target_product_if_exists(**converted_dict)
-            converted_dict["engagement"] = auto_create.get_target_engagement_if_exists(**converted_dict)
-        except (ValueError, TypeError) as e:
-            # Raise an explicit drf exception here
-            raise ValidationError(e)
-        if engagement := converted_dict.get("engagement"):
-            # Validate the resolved engagement's parent chain matches any provided identifiers
-            if (product := converted_dict.get("product")) and engagement.product_id != product.id:
-                msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
-                raise ValidationError(msg)
-            if (engagement_name := converted_dict.get("engagement_name")) and engagement.name != engagement_name:
-                msg = "The provided identifiers are inconsistent — the engagement name does not match the specified engagement."
-                raise ValidationError(msg)
-            return user_has_permission(
-                request.user, engagement, Permissions.Import_Scan_Result,
-            )
-        if engagement_id := converted_dict.get("engagement_id"):
-            # engagement_id doesn't exist
-            msg = f'Engagement "{engagement_id}" does not exist'
-            raise serializers.ValidationError(msg)
-
-        if not converted_dict.get("auto_create_context"):
-            raise_no_auto_create_import_validation_error(
-                None,
-                None,
-                converted_dict.get("engagement_name"),
-                converted_dict.get("product_name"),
-                converted_dict.get("product_type_name"),
-                converted_dict.get("engagement"),
-                converted_dict.get("product"),
-                converted_dict.get("product_type"),
-                "Need engagement_id or product_name + engagement_name to perform import",
-            )
-            return None
-        # the engagement doesn't exist, so we need to check if the user has
-        # requested and is allowed to use auto_create
-        return check_auto_create_permission(
-            request.user,
-            converted_dict.get("product"),
-            converted_dict.get("product_name"),
-            converted_dict.get("engagement"),
-            converted_dict.get("engagement_name"),
-            converted_dict.get("product_type"),
-            converted_dict.get("product_type_name"),
-            "Need engagement_id or product_name + engagement_name to perform import",
-        )
-
-
-class UserHasMetaImportPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        # permission check takes place before validation, so we don't have access to serializer.validated_data()
-        # and we have to validate ourselves unfortunately
-        auto_create = AutoCreateContextManager()
-        # Process the context to make an conversions needed. Catch any exceptions
-        # in this case and wrap them in a DRF exception
-        try:
-            converted_dict = auto_create.convert_querydict_to_dict(request.data)
-            auto_create.process_import_meta_data_from_dict(converted_dict)
-            # Get an existing product
-            product = auto_create.get_target_product_if_exists(**converted_dict)
-            if not product:
-                product = auto_create.get_target_product_by_id_if_exists(**converted_dict)
-        except (ValueError, TypeError) as e:
-            # Raise an explicit drf exception here
-            raise ValidationError(e)
-
-        if product:
-            # existing product, nothing special to check
-            return user_has_permission(
-                request.user, product, Permissions.Import_Scan_Result,
-            )
-        if product_id := converted_dict.get("product_id"):
-            # product_id doesn't exist
-            msg = f'Product "{product_id}" does not exist'
-            raise serializers.ValidationError(msg)
-        msg = "Need product_id or product_name to perform import"
-        raise serializers.ValidationError(msg)
-
-
-class UserHasProductPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product_Type,
-            "prod_type",
-            Permissions.Product_Type_Add_Product,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_View,
-            Permissions.Product_Edit,
-            Permissions.Product_Delete,
-        )
-
-
-class UserHasAssetPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product_Type,
-            "organization",
-            Permissions.Product_Type_Add_Product,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_View,
-            Permissions.Product_Edit,
-            Permissions.Product_Delete,
-        )
-
-
-class UserHasProductMemberPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Product_Manage_Members,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_View,
-            Permissions.Product_Manage_Members,
-            Permissions.Product_Member_Delete,
-        )
-
-
-class UserHasAssetMemberPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "asset", Permissions.Product_Manage_Members,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_View,
-            Permissions.Product_Manage_Members,
-            Permissions.Product_Member_Delete,
-        )
-
-
-class UserHasProductGroupPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Product_Group_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Group_View,
-            Permissions.Product_Group_Edit,
-            Permissions.Product_Group_Delete,
-        )
-
-
-class UserHasAssetGroupPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "asset", Permissions.Product_Group_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Group_View,
-            Permissions.Product_Group_Edit,
-            Permissions.Product_Group_Delete,
-        )
-
-
-class UserHasProductTypePermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        if request.method == "POST":
-            return user_has_global_permission(
-                request.user, Permissions.Product_Type_Add,
-            )
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Edit,
-            Permissions.Product_Type_Delete,
-        )
-
-
-class UserHasOrganizationPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        if request.method == "POST":
-            return user_has_global_permission(
-                request.user, Permissions.Product_Type_Add,
-            )
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Edit,
-            Permissions.Product_Type_Delete,
-        )
-
-
-class UserHasProductTypeMemberPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product_Type,
-            "product_type",
-            Permissions.Product_Type_Manage_Members,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Manage_Members,
-            Permissions.Product_Type_Member_Delete,
-        )
-
-
-class UserHasOrganizationMemberPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product_Type,
-            "organization",
-            Permissions.Product_Type_Manage_Members,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Manage_Members,
-            Permissions.Product_Type_Member_Delete,
-        )
-
-
-class UserHasProductTypeGroupPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product_Type,
-            "product_type",
-            Permissions.Product_Type_Group_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Type_Group_View,
-            Permissions.Product_Type_Group_Edit,
-            Permissions.Product_Type_Group_Delete,
-        )
-
-
-class UserHasOrganizationGroupPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product_Type,
-            "organization",
-            Permissions.Product_Type_Group_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_Type_Group_View,
-            Permissions.Product_Type_Group_Edit,
-            Permissions.Product_Type_Group_Delete,
-        )
-
-
-class UserHasReimportPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        # permission check takes place before validation, so we don't have access to serializer.validated_data()
-        # and we have to validate ourselves unfortunately
-        auto_create = AutoCreateContextManager()
-        # Process the context to make an conversions needed. Catch any exceptions
-        # in this case and wrap them in a DRF exception
-        try:
-            converted_dict = auto_create.convert_querydict_to_dict(request.data)
-            auto_create.process_import_meta_data_from_dict(converted_dict)
-            # engagement is not a declared field on ReImportScanSerializer and will be
-            # stripped during validation — don't use it in the permission check either,
-            # so the permission check resolves targets the same way execution does
-            converted_dict.pop("engagement", None)
-            converted_dict.pop("engagement_id", None)
-            # Get an existing product
-            converted_dict["product_type"] = auto_create.get_target_product_type_if_exists(**converted_dict)
-            converted_dict["product"] = auto_create.get_target_product_if_exists(**converted_dict)
-            converted_dict["engagement"] = auto_create.get_target_engagement_if_exists(**converted_dict)
-            converted_dict["test"] = auto_create.get_target_test_if_exists(**converted_dict)
-        except (ValueError, TypeError) as e:
-            # Raise an explicit drf exception here
-            raise ValidationError(e)
-
-        if test := converted_dict.get("test"):
-            # Validate the resolved test's parent chain matches any provided identifiers
-            if (product := converted_dict.get("product")) and test.engagement.product_id != product.id:
-                msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
-                raise ValidationError(msg)
-            if (engagement := converted_dict.get("engagement")) and test.engagement_id != engagement.id:
-                msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
-                raise ValidationError(msg)
-            # Also validate by name when the objects were not resolved (e.g. names that match no existing record)
-            if not converted_dict.get("product") and (product_name := converted_dict.get("product_name")) and test.engagement.product.name != product_name:
-                msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
-                raise ValidationError(msg)
-            if not converted_dict.get("engagement") and (engagement_name := converted_dict.get("engagement_name")) and test.engagement.name != engagement_name:
-                msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
-                raise ValidationError(msg)
-            return user_has_permission(
-                request.user, test, Permissions.Import_Scan_Result,
-            )
-        if test_id := converted_dict.get("test_id"):
-            # test_id doesn't exist
-            msg = f'Test "{test_id}" does not exist'
-            raise serializers.ValidationError(msg)
-
-        if not converted_dict.get("auto_create_context"):
-            raise_no_auto_create_import_validation_error(
-                converted_dict.get("test_title"),
-                converted_dict.get("scan_type"),
-                converted_dict.get("engagement_name"),
-                converted_dict.get("product_name"),
-                converted_dict.get("product_type_name"),
-                converted_dict.get("engagement"),
-                converted_dict.get("product"),
-                converted_dict.get("product_type"),
-                "Need test_id or product_name + engagement_name + scan_type to perform reimport",
-            )
-            return None
-        # the test doesn't exist, so we need to check if the user has
-        # requested and is allowed to use auto_create
-        return check_auto_create_permission(
-            request.user,
-            converted_dict.get("product"),
-            converted_dict.get("product_name"),
-            converted_dict.get("engagement"),
-            converted_dict.get("engagement_name"),
-            converted_dict.get("product_type"),
-            converted_dict.get("product_type_name"),
-            "Need test_id or product_name + engagement_name + scan_type to perform reimport",
-        )
-
-
-class UserHasTestPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Engagement, "engagement", Permissions.Test_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Test_View,
-            Permissions.Test_Edit,
-            Permissions.Test_Delete,
-        )
-
-
-class UserHasTestRelatedObjectPermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Test_View,
-        "put_permission": Permissions.Test_Edit,
-        "delete_permission": Permissions.Test_Edit,
-        "post_permission": Permissions.Test_Edit,
-    }
-
-
-class UserHasTestNotePermission(BaseRelatedObjectPermission):
-    permission_map = {
-        "get_permission": Permissions.Test_View,
-        "put_permission": Permissions.Test_Edit,
-        "delete_permission": Permissions.Test_Edit,
-        "post_permission": Permissions.Test_View,
-    }
-
-
-class UserHasTestImportPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Test, "test", Permissions.Test_Edit,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj.test,
-            Permissions.Test_View,
-            Permissions.Test_Edit,
-            Permissions.Test_Delete,
-        )
-
-
-class UserHasLanguagePermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Language_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Language_View,
-            Permissions.Language_Edit,
-            Permissions.Language_Delete,
-        )
-
-
-class UserHasProductAPIScanConfigurationPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product,
-            "product",
-            Permissions.Product_API_Scan_Configuration_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_API_Scan_Configuration_Edit,
-            Permissions.Product_API_Scan_Configuration_Delete,
-        )
-
-
-class UserHasAssetAPIScanConfigurationPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request,
-            Product,
-            "asset",
-            Permissions.Product_API_Scan_Configuration_Add,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj,
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_API_Scan_Configuration_Edit,
-            Permissions.Product_API_Scan_Configuration_Delete,
-        )
-
-
-class UserHasJiraProductPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        if request.method == "POST":
-            has_permission_result = True
-            engagement_id = request.data.get("engagement", None)
-            if engagement_id:
-                obj = get_object_or_404(Engagement, pk=engagement_id)
-                has_permission_result = (
-                    has_permission_result
-                    and user_has_permission(
-                        request.user, obj, Permissions.Engagement_Edit,
-                    )
-                )
-            product_id = request.data.get("product", None)
-            if product_id:
-                obj = get_object_or_404(Product, pk=product_id)
-                has_permission_result = (
-                    has_permission_result
-                    and user_has_permission(
-                        request.user, obj, Permissions.Product_Edit,
-                    )
-                )
-            return has_permission_result
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        has_permission_result = True
-        engagement = obj.engagement
-        if engagement:
-            has_permission_result = (
-                has_permission_result
-                and check_object_permission(
-                    request,
-                    engagement,
-                    Permissions.Engagement_View,
-                    Permissions.Engagement_Edit,
-                    Permissions.Engagement_Edit,
-                )
-            )
-        product = obj.product
-        if product:
-            has_permission_result = (
-                has_permission_result
-                and check_object_permission(
-                    request,
-                    product,
-                    Permissions.Product_View,
-                    Permissions.Product_Edit,
-                    Permissions.Product_Edit,
-                )
-            )
-        return has_permission_result
-
-
-class UserHasJiraIssuePermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        if request.method == "POST":
-            has_permission_result = True
-            engagement_id = request.data.get("engagement", None)
-            if engagement_id:
-                obj = get_object_or_404(Engagement, pk=engagement_id)
-                has_permission_result = (
-                    has_permission_result
-                    and user_has_permission(
-                        request.user, obj, Permissions.Engagement_Edit,
-                    )
-                )
-            finding_id = request.data.get("finding", None)
-            if finding_id:
-                obj = get_object_or_404(Finding, pk=finding_id)
-                has_permission_result = (
-                    has_permission_result
-                    and user_has_permission(
-                        request.user, obj, Permissions.Finding_Edit,
-                    )
-                )
-            finding_group_id = request.data.get("finding_group", None)
-            if finding_group_id:
-                obj = get_object_or_404(Finding_Group, pk=finding_group_id)
-                has_permission_result = (
-                    has_permission_result
-                    and user_has_permission(
-                        request.user, obj, Permissions.Finding_Group_Edit,
-                    )
-                )
-            return has_permission_result
-        return True
-
-    def has_object_permission(self, request, view, obj):
-        has_permission_result = True
-        engagement = obj.engagement
-        if engagement:
-            has_permission_result = (
-                has_permission_result
-                and check_object_permission(
-                    request,
-                    engagement,
-                    Permissions.Engagement_View,
-                    Permissions.Engagement_Edit,
-                    Permissions.Engagement_Edit,
-                )
-            )
-        finding = obj.finding
-        if finding:
-            has_permission_result = (
-                has_permission_result
-                and check_object_permission(
-                    request,
-                    finding,
-                    Permissions.Finding_View,
-                    Permissions.Finding_Edit,
-                    Permissions.Finding_Edit,
-                )
-            )
-        finding_group = obj.finding_group
-        if finding_group:
-            has_permission_result = (
-                has_permission_result
-                and check_object_permission(
-                    request,
-                    finding_group,
-                    Permissions.Finding_Group_View,
-                    Permissions.Finding_Group_Edit,
-                    Permissions.Finding_Group_Edit,
-                )
-            )
-        return has_permission_result
-
-
-class IsSuperUser(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return request.user and request.user.is_superuser
-
-
-class IsSuperUserOrGlobalOwner(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return user_is_superuser_or_global_owner(request.user)
-
-
-class UserHasEngagementPresetPermission(permissions.BasePermission):
-    def has_permission(self, request, view):
-        return check_post_permission(
-            request, Product, "product", Permissions.Product_Edit,
-        )
-
-    def has_object_permission(self, request, view, obj):
-        return check_object_permission(
-            request,
-            obj.product,
-            Permissions.Product_View,
-            Permissions.Product_Edit,
-            Permissions.Product_Edit,
-            Permissions.Product_Edit,
-        )
-
-
-class UserHasSLAPermission(BaseDjangoModelPermission):
-    django_model = SLA_Configuration
-
-
-class UserHasDevelopmentEnvironmentPermission(BaseDjangoModelPermission):
-    django_model = Development_Environment
-    # https://github.com/DefectDojo/django-DefectDojo/blob/963d4a35bfd8f5138330f0d70595a755fa4999b0/dojo/user/utils.py#L93
-    # It looks like view permission was explicitly not supported, so I assume
-    # reading these endpoints are not necessarily restricted (unless you're auth'd of course)
-    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
-    # It looks like view permission was explicitly not supported, so I assume
-    # reading these endpoints are not necessarily restricted (unless you're auth'd of course)
-    request_method_permission_map = {
-        "POST": "add",
-        "PUT": "change",
-        "PATCH": "change",
-        "DELETE": "delete",
-    }
-
-
-def raise_no_auto_create_import_validation_error(
-    test_title,
-    scan_type,
-    engagement_name,
-    product_name,
-    product_type_name,
-    engagement,
-    product,
-    product_type,
-    error_message,
-):
-    # check for mandatory fields first
-    if not product_name:
-        msg = "product_name parameter missing"
-        raise ValidationError(msg)
-
-    if not engagement_name:
-        msg = "engagement_name parameter missing"
-        raise ValidationError(msg)
-
-    if product_type_name and not product_type:
-        msg = f'Product Type "{product_type_name}" does not exist'
-        raise serializers.ValidationError(msg)
-
-    if product_name and not product:
-        if product_type_name:
-            msg = f'Product "{product_name}" does not exist in Product_Type "{product_type_name}"'
-            raise serializers.ValidationError(msg)
-        msg = f'Product "{product_name}" does not exist'
-        raise serializers.ValidationError(msg)
-
-    if engagement_name and not engagement:
-        msg = f'Engagement "{engagement_name}" does not exist in Product "{product_name}"'
-        raise serializers.ValidationError(msg)
-
-    # these are only set for reimport
-    if test_title:
-        msg = f'Test "{test_title}" with scan_type "{scan_type}" does not exist in Engagement "{engagement_name}"'
-        raise serializers.ValidationError(msg)
-
-    if scan_type:
-        msg = f'Test with scan_type "{scan_type}" does not exist in Engagement "{engagement_name}"'
-        raise serializers.ValidationError(msg)
-
-    raise ValidationError(error_message)
-
-
-def check_auto_create_permission(
-    user,
-    product,
-    product_name,
-    engagement,
-    engagement_name,
-    product_type,
-    product_type_name,
-    error_message,
-):
-    """
-    For an existing engagement, to be allowed to import a scan, the following must all be True:
-    - User must have Import_Scan_Result permission for this Engagement
-
-    For an existing product, to be allowed to import into a new engagement with name `engagement_name`, the following must all be True:
-    - Product with name `product_name`  must already exist;
-    - User must have Engagement_Add permission for this Product
-    - User must have Import_Scan_Result permission for this Product
-
-    If the product doesn't exist yet, to be allowed to import into a new product with name `product_name` and prod_type `product_type_name`,
-    the following must all be True:
-    - `auto_create_context` must be True
-    - Product_Type already exists, or the user has the Product_Type_Add permission
-    - User must have Product_Type_Add_Product permission for the Product_Type, or the user has the Product_Type_Add permission
-    """
-    if not product_name:
-        msg = "product_name parameter missing"
-        raise ValidationError(msg)
-
-    if not engagement_name:
-        msg = "engagement_name parameter missing"
-        raise ValidationError(msg)
-
-    if engagement:
-        # Validate the resolved engagement's parent chain matches any provided names
-        if product is not None and engagement.product_id != product.id:
-            msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
-            raise ValidationError(msg)
-        return user_has_permission(
-            user, engagement, Permissions.Import_Scan_Result,
-        )
-
-    if product and product_name and engagement_name:
-        if not user_has_permission(user, product, Permissions.Engagement_Add):
-            msg = f'No permission to create engagements in product "{product_name}"'
-            raise PermissionDenied(msg)
-
-        if not user_has_permission(
-            user, product, Permissions.Import_Scan_Result,
-        ):
-            msg = f'No permission to import scans into product "{product_name}"'
-            raise PermissionDenied(msg)
-
-        # all good
-        return True
-
-    if not product and product_name:
-        if not product_type_name:
-            msg = f'Product "{product_name}" does not exist and no product_type_name provided to create the new product in'
-            raise serializers.ValidationError(msg)
-
-        if not product_type:
-            if not user_has_global_permission(
-                user, Permissions.Product_Type_Add,
-            ):
-                msg = f'No permission to create product_type "{product_type_name}"'
-                raise PermissionDenied(msg)
-            # new product type can be created with current user as owner, so
-            # all objects in it can be created as well
-            return True
-        if not user_has_permission(
-            user, product_type, Permissions.Product_Type_Add_Product,
-        ):
-            msg = f'No permission to create products in product_type "{product_type}"'
-            raise PermissionDenied(msg)
-
-        # product can be created, so objects in it can be created as well
-        return True
-
-    raise ValidationError(error_message)
-
-
-class UserHasConfigurationPermissionStaff(permissions.DjangoModelPermissions):
-    # Override map to also provide 'view' permissions
-    perms_map = {
-        "GET": ["%(app_label)s.view_%(model_name)s"],
-        "OPTIONS": [],
-        "HEAD": [],
-        "POST": ["%(app_label)s.add_%(model_name)s"],
-        "PUT": ["%(app_label)s.change_%(model_name)s"],
-        "PATCH": ["%(app_label)s.change_%(model_name)s"],
-        "DELETE": ["%(app_label)s.delete_%(model_name)s"],
-    }
-
-    def has_permission(self, request, view):
-        return super().has_permission(request, view)
-
-
-class UserHasConfigurationPermissionSuperuser(
-    permissions.DjangoModelPermissions,
-):
-    # Override map to also provide 'view' permissions
-    perms_map = {
-        "GET": ["%(app_label)s.view_%(model_name)s"],
-        "OPTIONS": [],
-        "HEAD": [],
-        "POST": ["%(app_label)s.add_%(model_name)s"],
-        "PUT": ["%(app_label)s.change_%(model_name)s"],
-        "PATCH": ["%(app_label)s.change_%(model_name)s"],
-        "DELETE": ["%(app_label)s.delete_%(model_name)s"],
-    }
-
-    def has_permission(self, request, view):
-        return super().has_permission(request, view)
+"""
+Backward-compat re-export for callers that still import permission classes
+from ``dojo.api_v2.permissions``. The canonical home is
+``dojo.authorization.api_permissions`` after the legacy authorization
+consolidation; this shim lets sub-package modules consolidated from
+upstream (``dojo/notifications/api/views.py``, etc.) keep their old import
+path.
+"""
+from dojo.authorization.api_permissions import *  # noqa: F403
diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py
index 72a7752f7df..db2b0bd4280 100644
--- a/dojo/api_v2/serializers.py
+++ b/dojo/api_v2/serializers.py
@@ -9,7 +9,7 @@
 import six
 import tagulous
 from django.conf import settings
-from django.contrib.auth.models import Group, Permission
+from django.contrib.auth.models import Permission
 from django.contrib.auth.password_validation import validate_password
 from django.core.exceptions import PermissionDenied, ValidationError
 from django.db import transaction
@@ -26,7 +26,6 @@
 import dojo.finding.helper as finding_helper
 import dojo.risk_acceptance.helper as ra_helper
 from dojo.authorization.authorization import user_has_permission
-from dojo.authorization.roles_permissions import Permissions
 from dojo.celery_dispatch import dojo_dispatch_task
 from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import
 from dojo.finding.helper import (
@@ -35,7 +34,6 @@
     save_vulnerability_ids_template,
 )
 from dojo.finding.queries import get_authorized_findings
-from dojo.group.utils import get_auth_group_name
 from dojo.importers.auto_create_context import AutoCreateContextManager
 from dojo.importers.base_importer import BaseImporter
 from dojo.importers.default_importer import DefaultImporter
@@ -52,8 +50,6 @@
     BurpRawRequestResponse,
     Check_List,
     Development_Environment,
-    Dojo_Group,
-    Dojo_Group_Member,
     Dojo_User,
     DojoMeta,
     Endpoint,
@@ -65,7 +61,6 @@
     Finding,
     Finding_Group,
     Finding_Template,
-    Global_Role,
     Language_Type,
     Languages,
     Network_Locations,
@@ -74,14 +69,9 @@
     Notes,
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
     Product_Type,
-    Product_Type_Group,
-    Product_Type_Member,
     Regulation,
     Risk_Acceptance,
-    Role,
     SLA_Configuration,
     Sonarqube_Issue,
     Sonarqube_Issue_Transition,
@@ -519,6 +509,7 @@ class Meta:
             "date_joined",
             "last_login",
             "is_active",
+            "is_staff",
             "is_superuser",
             "token_last_reset",
             "password_last_reset",
@@ -652,187 +643,6 @@ class Meta:
         fields = ("id", "username", "first_name", "last_name")
 
 
-class RoleSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Role
-        fields = "__all__"
-
-
-class DojoGroupSerializer(serializers.ModelSerializer):
-    configuration_permissions = serializers.PrimaryKeyRelatedField(
-        allow_null=True,
-        queryset=Permission.objects.filter(
-            codename__in=get_configuration_permissions_codenames(),
-        ),
-        many=True,
-        required=False,
-        source="auth_group.permissions",
-    )
-
-    class Meta:
-        model = Dojo_Group
-        exclude = ("auth_group",)
-
-    def to_representation(self, instance):
-        if not instance.auth_group:
-            auth_group = Group(name=get_auth_group_name(instance))
-            auth_group.save()
-            instance.auth_group = auth_group
-            members = instance.users.all()
-            for member in members:
-                auth_group.user_set.add(member)
-            instance.save()
-        ret = super().to_representation(instance)
-        # This will show only "configuration_permissions" even if user has also
-        # other permissions
-        all_permissions = set(ret["configuration_permissions"])
-        allowed_configuration_permissions = set(
-            self.fields[
-                "configuration_permissions"
-            ].child_relation.queryset.values_list("id", flat=True),
-        )
-        ret["configuration_permissions"] = list(
-            all_permissions.intersection(allowed_configuration_permissions),
-        )
-
-        return ret
-
-    def create(self, validated_data):
-        new_configuration_permissions = None
-        if (
-            "auth_group" in validated_data
-            and "permissions" in validated_data["auth_group"]
-        ):  # This field was renamed from "configuration_permissions" in the meantime
-            new_configuration_permissions = set(
-                validated_data.pop("auth_group")["permissions"],
-            )
-
-        instance = super().create(validated_data)
-
-        # This will update only Permissions from category
-        # "configuration_permissions". There are no other Permissions.
-        if new_configuration_permissions:
-            instance.auth_group.permissions.set(new_configuration_permissions)
-
-        return instance
-
-    def update(self, instance, validated_data):
-        permissions_in_payload = None
-        new_configuration_permissions = None
-        if (
-            "auth_group" in validated_data
-            and "permissions" in validated_data["auth_group"]
-        ):  # This field was renamed from "configuration_permissions" in the meantime
-            permissions_in_payload = validated_data.pop("auth_group")["permissions"]
-            new_configuration_permissions = set(permissions_in_payload)
-
-        instance = super().update(instance, validated_data)
-
-        # This will update only Permissions from category
-        # "configuration_permissions". Others will be untouched
-        if new_configuration_permissions:
-            allowed_configuration_permissions = set(
-                self.fields[
-                    "configuration_permissions"
-                ].child_relation.queryset.all(),
-            )
-            non_configuration_permissions = (
-                set(instance.auth_group.permissions.all())
-                - allowed_configuration_permissions
-            )
-            new_permissions = non_configuration_permissions.union(
-                new_configuration_permissions,
-            )
-            instance.auth_group.permissions.set(new_permissions)
-
-        # Clear all configuration permissions if an empty list is provided
-        if isinstance(permissions_in_payload, list) and len(permissions_in_payload) == 0:
-            instance.auth_group.permissions.clear()
-
-        return instance
-
-
-class DojoGroupMemberSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Dojo_Group_Member
-        fields = "__all__"
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("group") != self.instance.group
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("group"),
-                Permissions.Group_Manage_Members,
-            )
-        ):
-            msg = "You are not permitted to add a user to this group"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("group") != self.instance.group
-            or data.get("user") != self.instance.user
-        ):
-            members = Dojo_Group_Member.objects.filter(
-                group=data.get("group"), user=data.get("user"),
-            )
-            if members.count() > 0:
-                msg = "Dojo_Group_Member already exists"
-                raise ValidationError(msg)
-
-        if self.instance is not None and not data.get("role").is_owner:
-            owners = (
-                Dojo_Group_Member.objects.filter(
-                    group=data.get("group"), role__is_owner=True,
-                )
-                .exclude(id=self.instance.id)
-                .count()
-            )
-            if owners < 1:
-                msg = "There must be at least one owner"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("group"),
-            Permissions.Group_Add_Owner,
-        ):
-            msg = "You are not permitted to add a user as Owner to this group"
-            raise PermissionDenied(msg)
-
-        return data
-
-
-class GlobalRoleSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Global_Role
-        fields = "__all__"
-
-    def validate(self, data):
-        user = None
-        group = None
-
-        if self.instance is not None:
-            user = self.instance.user
-            group = self.instance.group
-
-        if "user" in data:
-            user = data.get("user")
-        if "group" in data:
-            group = data.get("group")
-
-        if user is None and group is None:
-            msg = "Global_Role must have either user or group"
-            raise ValidationError(msg)
-        if user is not None and group is not None:
-            msg = "Global_Role cannot have both user and group"
-            raise ValidationError(msg)
-
-        return data
-
-
 class AddUserSerializer(serializers.ModelSerializer):
     class Meta:
         model = User
@@ -911,182 +721,6 @@ class Meta:
         fields = ["path"]
 
 
-class ProductMemberSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Product_Member
-        fields = "__all__"
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("product") != self.instance.product
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("product"),
-                Permissions.Product_Manage_Members,
-            )
-        ):
-            msg = "You are not permitted to add a member to this product"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("product") != self.instance.product
-            or data.get("user") != self.instance.user
-        ):
-            members = Product_Member.objects.filter(
-                product=data.get("product"), user=data.get("user"),
-            )
-            if members.count() > 0:
-                msg = "Product_Member already exists"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("product"),
-            Permissions.Product_Member_Add_Owner,
-        ):
-            msg = "You are not permitted to add a member as Owner to this product"
-            raise PermissionDenied(msg)
-
-        return data
-
-
-class ProductGroupSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Product_Group
-        fields = "__all__"
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("product") != self.instance.product
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("product"),
-                Permissions.Product_Group_Add,
-            )
-        ):
-            msg = "You are not permitted to add a group to this product"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("product") != self.instance.product
-            or data.get("group") != self.instance.group
-        ):
-            members = Product_Group.objects.filter(
-                product=data.get("product"), group=data.get("group"),
-            )
-            if members.count() > 0:
-                msg = "Product_Group already exists"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("product"),
-            Permissions.Product_Group_Add_Owner,
-        ):
-            msg = "You are not permitted to add a group as Owner to this product"
-            raise PermissionDenied(msg)
-
-        return data
-
-
-class ProductTypeMemberSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Product_Type_Member
-        fields = "__all__"
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("product_type") != self.instance.product_type
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("product_type"),
-                Permissions.Product_Type_Manage_Members,
-            )
-        ):
-            msg = "You are not permitted to add a member to this product type"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("product_type") != self.instance.product_type
-            or data.get("user") != self.instance.user
-        ):
-            members = Product_Type_Member.objects.filter(
-                product_type=data.get("product_type"), user=data.get("user"),
-            )
-            if members.count() > 0:
-                msg = "Product_Type_Member already exists"
-                raise ValidationError(msg)
-
-        if self.instance is not None and not data.get("role").is_owner:
-            owners = (
-                Product_Type_Member.objects.filter(
-                    product_type=data.get("product_type"), role__is_owner=True,
-                )
-                .exclude(id=self.instance.id)
-                .count()
-            )
-            if owners < 1:
-                msg = "There must be at least one owner"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("product_type"),
-            Permissions.Product_Type_Member_Add_Owner,
-        ):
-            msg = "You are not permitted to add a member as Owner to this product type"
-            raise PermissionDenied(msg)
-
-        return data
-
-
-class ProductTypeGroupSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Product_Type_Group
-        fields = "__all__"
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("product_type") != self.instance.product_type
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("product_type"),
-                Permissions.Product_Type_Group_Add,
-            )
-        ):
-            msg = "You are not permitted to add a group to this product type"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("product_type") != self.instance.product_type
-            or data.get("group") != self.instance.group
-        ):
-            members = Product_Type_Group.objects.filter(
-                product_type=data.get("product_type"), group=data.get("group"),
-            )
-            if members.count() > 0:
-                msg = "Product_Type_Group already exists"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("product_type"),
-            Permissions.Product_Type_Group_Add_Owner,
-        ):
-            msg = "You are not permitted to add a group as Owner to this product type"
-            raise PermissionDenied(msg)
-
-        return data
-
-
 class ProductTypeSerializer(serializers.ModelSerializer):
     class Meta:
         model = Product_Type
@@ -1112,7 +746,7 @@ def validate(self, data):
             and not user_has_permission(
                 self.context["request"].user,
                 data.get("product"),
-                Permissions.Engagement_Edit,
+                "edit",
             )
         ):
             msg = "You are not permitted to edit engagements in the destination product"
@@ -1565,7 +1199,7 @@ def validate_findings_have_same_engagement(finding_objects: list[Finding]):
         findings = data.get("accepted_findings", [])
         findings_ids = [x.id for x in findings]
         finding_objects = Finding.objects.filter(id__in=findings_ids)
-        authed_findings = get_authorized_findings(Permissions.Risk_Acceptance).filter(id__in=findings_ids)
+        authed_findings = get_authorized_findings("edit").filter(id__in=findings_ids)
         if len(findings) != len(authed_findings):
             msg = "You are not permitted to add one or more selected findings to this risk acceptance"
             raise PermissionDenied(msg)
@@ -1750,7 +1384,7 @@ def get_accepted_risks(self, obj):
         request = self.context.get("request")
         if request is None:
             return []
-        if not user_has_permission(request.user, obj, Permissions.Risk_Acceptance):
+        if not user_has_permission(request.user, obj, "edit"):
             return []
         return RiskAcceptanceSerializer(
             obj.risk_acceptance_set.all(), many=True,
@@ -2897,7 +2531,7 @@ def validate(self, data):
                 })
 
             # Ensure selected user is authorized (Finding_Edit)
-            authorized_users = get_authorized_users(Permissions.Finding_Edit, user=request_user)
+            authorized_users = get_authorized_users("edit", user=request_user)
             if not authorized_users.filter(id=mitigated_by_user.id).exists():
                 raise serializers.ValidationError({
                     "mitigated_by": [
@@ -2969,24 +2603,6 @@ class Meta:
         model = System_Settings
         fields = "__all__"
 
-    def validate(self, data):
-        if self.instance is not None:
-            default_group = self.instance.default_group
-            default_group_role = self.instance.default_group_role
-
-        if "default_group" in data:
-            default_group = data["default_group"]
-        if "default_group_role" in data:
-            default_group_role = data["default_group_role"]
-
-        if (default_group is None and default_group_role is not None) or (
-            default_group is not None and default_group_role is None
-        ):
-            msg = "default_group and default_group_role must either both be set or both be empty."
-            raise ValidationError(msg)
-
-        return data
-
 
 class CeleryStatusSerializer(serializers.Serializer):
     worker_status = serializers.BooleanField(read_only=True)
@@ -3049,10 +2665,6 @@ def validate(self, data):
 class UserProfileSerializer(serializers.Serializer):
     user = UserSerializer(many=False)
     user_contact_info = UserContactInfoSerializer(many=False, required=False)
-    global_role = GlobalRoleSerializer(many=False, required=False)
-    dojo_group_member = DojoGroupMemberSerializer(many=True)
-    product_type_member = ProductTypeMemberSerializer(many=True)
-    product_member = ProductMemberSerializer(many=True)
 
 
 class DeletePreviewSerializer(serializers.Serializer):
diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py
index 65a7dcc0bbf..9583a7a9e9c 100644
--- a/dojo/api_v2/views.py
+++ b/dojo/api_v2/views.py
@@ -41,13 +41,12 @@
     mixins as dojo_mixins,
 )
 from dojo.api_v2 import (
-    permissions,
     prefetch,
     serializers,
 )
 from dojo.api_v2.prefetch.prefetcher import _Prefetcher
+from dojo.authorization import api_permissions as permissions
 from dojo.authorization.authorization import user_has_permission_or_403
-from dojo.authorization.roles_permissions import Permissions
 from dojo.celery_dispatch import dojo_dispatch_task
 from dojo.endpoint.queries import (
     get_authorized_endpoint_status,
@@ -79,10 +78,6 @@
     reset_finding_duplicate_status_internal,
     set_finding_as_original_internal,
 )
-from dojo.group.queries import (
-    get_authorized_group_members,
-    get_authorized_groups,
-)
 from dojo.importers.auto_create_context import AutoCreateContextManager
 from dojo.jira import services as jira_services
 from dojo.labels import get_labels
@@ -92,8 +87,6 @@
     BurpRawRequestResponse,
     Check_List,
     Development_Environment,
-    Dojo_Group,
-    Dojo_Group_Member,
     Dojo_User,
     DojoMeta,
     Endpoint,
@@ -103,7 +96,6 @@
     FileUpload,
     Finding,
     Finding_Template,
-    Global_Role,
     Language_Type,
     Languages,
     Network_Locations,
@@ -112,14 +104,9 @@
     Notes,
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
     Product_Type,
-    Product_Type_Group,
-    Product_Type_Member,
     Regulation,
     Risk_Acceptance,
-    Role,
     SLA_Configuration,
     Sonarqube_Issue,
     Sonarqube_Issue_Transition,
@@ -139,13 +126,9 @@
     get_authorized_engagement_presets,
     get_authorized_languages,
     get_authorized_product_api_scan_configurations,
-    get_authorized_product_groups,
-    get_authorized_product_members,
     get_authorized_products,
 )
 from dojo.product_type.queries import (
-    get_authorized_product_type_groups,
-    get_authorized_product_type_members,
     get_authorized_product_types,
 )
 from dojo.query_utils import build_count_subquery
@@ -246,76 +229,6 @@ def finalize_response(self, request, response, *args, **kwargs):
 
 
 # Authorization: authenticated users
-class RoleViewSet(viewsets.ReadOnlyModelViewSet):
-    serializer_class = serializers.RoleSerializer
-    queryset = Role.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "name"]
-    permission_classes = (IsAuthenticated,)
-
-    def get_queryset(self):
-        return Role.objects.all().order_by("id")
-
-
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class DojoGroupViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.DojoGroupSerializer
-    queryset = Dojo_Group.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "name", "social_provider"]
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasDojoGroupPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_groups(Permissions.Group_View).distinct()
-
-
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class DojoGroupMemberViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.DojoGroupMemberSerializer
-    queryset = Dojo_Group_Member.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "group_id", "user_id"]
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasDojoGroupMemberPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_group_members(Permissions.Group_View).distinct()
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
-
-
-# Authorization: superuser
-@extend_schema_view(**schema_with_prefetch())
-class GlobalRoleViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.GlobalRoleSerializer
-    queryset = Global_Role.objects.all()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "user", "group", "role"]
-    permission_classes = (permissions.IsSuperUser, DjangoModelPermissions)
-
-    def get_queryset(self):
-        return Global_Role.objects.all().order_by("id")
-
-
 # Authorization: object-based
 # @extend_schema_view(**schema_with_prefetch())
 # Nested models with prefetch make the response schema too long for Swagger UI
@@ -337,7 +250,7 @@ def get_queryset(self):
             Finding.objects.filter(endpoints=OuterRef("pk"), active=True),
             group_field="endpoints",
         )
-        return get_authorized_endpoints(Permissions.Location_View).annotate(
+        return get_authorized_endpoints("view").annotate(
             active_finding_count=Coalesce(active_finding_subquery, Value(0)),
         ).distinct()
 
@@ -409,7 +322,7 @@ class EndpointStatusViewSet(
 
     def get_queryset(self):
         return get_authorized_endpoint_status(
-            Permissions.Location_View,
+            "view",
         ).distinct()
 
 
@@ -446,7 +359,7 @@ def destroy(self, request, *args, **kwargs):
 
     def get_queryset(self):
         return (
-            get_authorized_engagements(Permissions.Engagement_View)
+            get_authorized_engagements("view")
             .prefetch_related("notes", "risk_acceptance", "files")
             .distinct()
         )
@@ -749,7 +662,7 @@ def destroy(self, request, pk=None):
 
     def get_queryset(self):
         return (
-            get_authorized_risk_acceptances(Permissions.Risk_Acceptance)
+            get_authorized_risk_acceptances("edit")
             .prefetch_related(
                 "notes", "engagement_set", "owner", "accepted_findings",
             )
@@ -865,7 +778,7 @@ class AppAnalysisViewSet(
     )
 
     def get_queryset(self):
-        return get_authorized_app_analysis(Permissions.Product_View)
+        return get_authorized_app_analysis("view")
 
 
 # Authorization: configuration
@@ -956,7 +869,7 @@ def perform_update(self, serializer):
     def get_queryset(self):
         if settings.V3_FEATURE_LOCATIONS:
             findings = get_authorized_findings(
-                Permissions.Finding_View,
+                "view",
             ).prefetch_related(
                 "locations__location__url",
                 "reviewers",
@@ -980,7 +893,7 @@ def get_queryset(self):
         else:
             # TODO: Delete this after the move to Locations
             findings = get_authorized_findings(
-                Permissions.Finding_View,
+                "view",
             ).prefetch_related(
                 "endpoints",
                 "reviewers",
@@ -1692,7 +1605,7 @@ class ProductAPIScanConfigurationViewSet(
 
     def get_queryset(self):
         return get_authorized_product_api_scan_configurations(
-            Permissions.Product_API_Scan_Configuration_View,
+            "view",
         )
 
 
@@ -1712,7 +1625,7 @@ class DojoMetaViewSet(
     )
 
     def get_queryset(self):
-        return get_authorized_dojo_meta(Permissions.Product_View)
+        return get_authorized_dojo_meta("view")
 
     @extend_schema(
         methods=["post", "patch"],
@@ -1749,9 +1662,9 @@ def _fetch_and_authorize_parents(self, request, permission_map):
     def process_post(self, request):
         data = request.data
         parents = self._fetch_and_authorize_parents(request, {
-            "product": (Product, Permissions.Product_Edit),
-            "finding": (Finding, Permissions.Finding_Edit),
-            "endpoint": (Endpoint, Permissions.Location_Edit),
+            "product": (Product, "edit"),
+            "finding": (Finding, "edit"),
+            "endpoint": (Endpoint, "edit"),
         })
         metalist = data.get("metadata")
         for metadata in metalist:
@@ -1769,9 +1682,9 @@ def process_post(self, request):
     def process_patch(self, request):
         data = request.data
         parents = self._fetch_and_authorize_parents(request, {
-            "product": (Product, Permissions.Product_Edit),
-            "finding": (Finding, Permissions.Finding_Edit),
-            "endpoint": (Endpoint, Permissions.Location_Edit),
+            "product": (Product, "edit"),
+            "finding": (Finding, "edit"),
+            "endpoint": (Endpoint, "edit"),
         })
         metalist = data.get("metadata")
         for metadata in metalist:
@@ -1809,7 +1722,7 @@ class ProductViewSet(
     )
 
     def get_queryset(self):
-        return get_authorized_products(Permissions.Product_View).distinct()
+        return get_authorized_products("view").distinct()
 
     def destroy(self, request, *args, **kwargs):
         instance = self.get_object()
@@ -1870,60 +1783,6 @@ def generate_report(self, request, pk=None):
 
 # Authorization: object-based
 @extend_schema_view(**schema_with_prefetch())
-class ProductMemberViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.ProductMemberSerializer
-    queryset = Product_Member.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "product_id", "user_id"]
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasProductMemberPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_product_members(
-            Permissions.Product_View,
-        ).distinct()
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
-
-
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class ProductGroupViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.ProductGroupSerializer
-    queryset = Product_Group.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "product_id", "group_id"]
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasProductGroupPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_product_groups(
-            Permissions.Product_Group_View,
-        ).distinct()
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
-
-
 # Authorization: object-based
 @extend_schema_view(**schema_with_prefetch())
 class ProductTypeViewSet(
@@ -1947,21 +1806,9 @@ class ProductTypeViewSet(
 
     def get_queryset(self):
         return get_authorized_product_types(
-            Permissions.Product_Type_View,
+            "view",
         ).distinct()
 
-    # Overwrite perfom_create of CreateModelMixin to add current user as owner
-    def perform_create(self, serializer):
-        serializer.save()
-        product_type_data = serializer.data
-        product_type_data.pop("authorization_groups")
-        product_type_data.pop("members")
-        member = Product_Type_Member()
-        member.user = self.request.user
-        member.product_type = Product_Type(**product_type_data)
-        member.role = Role.objects.get(is_owner=True)
-        member.save()
-
     def destroy(self, request, *args, **kwargs):
         instance = self.get_object()
         if get_setting("ASYNC_OBJECT_DELETE"):
@@ -2013,76 +1860,6 @@ def generate_report(self, request, pk=None):
         return Response(report.data)
 
 
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class ProductTypeMemberViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.ProductTypeMemberSerializer
-    queryset = Product_Type_Member.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "product_type_id", "user_id"]
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasProductTypeMemberPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_product_type_members(
-            Permissions.Product_Type_View,
-        ).distinct()
-
-    def destroy(self, request, *args, **kwargs):
-        instance = self.get_object()
-        if instance.role.is_owner:
-            owners = Product_Type_Member.objects.filter(
-                product_type=instance.product_type, role__is_owner=True,
-            ).count()
-            if owners <= 1:
-                return Response(
-                    "There must be at least one owner",
-                    status=status.HTTP_400_BAD_REQUEST,
-                )
-        self.perform_destroy(instance)
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
-
-
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class ProductTypeGroupViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.ProductTypeGroupSerializer
-    queryset = Product_Type_Group.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_fields = ["id", "product_type_id", "group_id"]
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasProductTypeGroupPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_product_type_groups(
-            Permissions.Product_Type_Group_View,
-        ).distinct()
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
-
-
 # Authorization: authenticated, configuration
 class DevelopmentEnvironmentViewSet(
     DojoModelViewSet,
@@ -2115,7 +1892,7 @@ def risk_application_model_class(self):
 
     def get_queryset(self):
         return (
-            get_authorized_tests(Permissions.Test_View)
+            get_authorized_tests("view")
             .prefetch_related("notes", "files")
             .distinct()
         )
@@ -2350,7 +2127,7 @@ class TestImportViewSet(
 
     def get_queryset(self):
         return get_authorized_test_imports(
-            Permissions.Test_View,
+            "view",
         ).prefetch_related(
             "test_import_finding_action_set",
             "findings_affected",
@@ -2426,7 +2203,7 @@ class ToolProductSettingsViewSet(
     )
 
     def get_queryset(self):
-        return get_authorized_tool_product_settings(Permissions.Product_View)
+        return get_authorized_tool_product_settings("view")
 
 
 # Authorization: configuration
@@ -2523,20 +2300,10 @@ def get(self, request, _=None):
         user_contact_info = (
             user.usercontactinfo if hasattr(user, "usercontactinfo") else None
         )
-        global_role = (
-            user.global_role if hasattr(user, "global_role") else None
-        )
-        dojo_group_member = Dojo_Group_Member.objects.filter(user=user)
-        product_type_member = Product_Type_Member.objects.filter(user=user)
-        product_member = Product_Member.objects.filter(user=user)
         serializer = serializers.UserProfileSerializer(
             {
                 "user": user,
                 "user_contact_info": user_contact_info,
-                "global_role": global_role,
-                "dojo_group_member": dojo_group_member,
-                "product_type_member": product_type_member,
-                "product_member": product_member,
             },
             many=False,
         )
@@ -2612,7 +2379,7 @@ def perform_create(self, serializer):
             pghistory.context(test_id=test_id)
 
     def get_queryset(self):
-        return get_authorized_tests(Permissions.Import_Scan_Result)
+        return get_authorized_tests("import")
 
 
 # Authorization: authenticated users, DjangoModelPermissions
@@ -2644,7 +2411,7 @@ def perform_create(self, serializer):
         serializer.save()
 
     def get_queryset(self):
-        return get_authorized_products(Permissions.Location_Edit)
+        return get_authorized_products("edit")
 
 
 # Authorization: configuration
@@ -2676,7 +2443,7 @@ class LanguageViewSet(
     )
 
     def get_queryset(self):
-        return get_authorized_languages(Permissions.Language_View).distinct()
+        return get_authorized_languages("view").distinct()
 
 
 # Authorization: object-based
@@ -2690,7 +2457,7 @@ class ImportLanguagesView(mixins.CreateModelMixin, viewsets.GenericViewSet):
     )
 
     def get_queryset(self):
-        return get_authorized_products(Permissions.Language_Add)
+        return get_authorized_products("add")
 
 
 # Authorization: object-based
@@ -2732,7 +2499,7 @@ class ReImportScanView(mixins.CreateModelMixin, viewsets.GenericViewSet):
     )
 
     def get_queryset(self):
-        return get_authorized_tests(Permissions.Import_Scan_Result)
+        return get_authorized_tests("import")
 
     def perform_create(self, serializer):
         auto_create = AutoCreateContextManager()
@@ -2812,7 +2579,7 @@ def get_queryset(self):
         return (
             BurpRawRequestResponse.objects.filter(
                 finding__in=get_authorized_findings(
-                    Permissions.Finding_View,
+                    "view",
                 ),
             )
             .exclude(
@@ -3212,7 +2979,7 @@ class EngagementPresetsViewset(
     )
 
     def get_queryset(self):
-        return get_authorized_engagement_presets(Permissions.Product_View)
+        return get_authorized_engagement_presets("view")
 
 
 class NetworkLocationsViewset(
diff --git a/dojo/apps.py b/dojo/apps.py
index 38035547229..4b1af1ef192 100644
--- a/dojo/apps.py
+++ b/dojo/apps.py
@@ -72,9 +72,14 @@ def ready(self):
 
         register_check(check_configuration_deduplication, "dojo")
 
+        # Trigger registration of the OS authorization queryset filters.
+        # query_registrations.py is no longer imported by the package
+        # __init__ (to avoid circular import edge cases during early model
+        # loading) — we do it here once all models are ready.
         # Load any signals here that will be ready for runtime
         # Importing the signals file is good enough if using the receiver decorator
         import dojo.announcement.signals  # noqa: PLC0415, F401 raised: AppRegistryNotReady
+        import dojo.authorization.query_registrations  # noqa: PLC0415, F401
         import dojo.benchmark.signals  # noqa: PLC0415, F401 raised: AppRegistryNotReady
 
         # TODO: Delete this after the move to Locations
diff --git a/dojo/asset/api/filters.py b/dojo/asset/api/filters.py
index 91281a9ebe2..237c8e3af7f 100644
--- a/dojo/asset/api/filters.py
+++ b/dojo/asset/api/filters.py
@@ -16,8 +16,6 @@
 from dojo.models import (
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
 )
 
 labels = get_labels()
@@ -104,19 +102,3 @@ class ApiAssetFilter(DojoFilter):
             ("user_records", "user_records"),
         ),
     )
-
-
-class AssetMemberFilterSet(FilterSet):
-    asset_id = NumberFilter(field_name="product_id")
-
-    class Meta:
-        model = Product_Member
-        fields = ("id", "user_id")
-
-
-class AssetGroupFilterSet(FilterSet):
-    asset_id = NumberFilter(field_name="product_id")
-
-    class Meta:
-        model = Product_Group
-        fields = ("id", "group_id")
diff --git a/dojo/asset/api/serializers.py b/dojo/asset/api/serializers.py
index 4b3bd0e9d5d..70c589fc4be 100644
--- a/dojo/asset/api/serializers.py
+++ b/dojo/asset/api/serializers.py
@@ -1,15 +1,10 @@
 from rest_framework import serializers
-from rest_framework.exceptions import PermissionDenied, ValidationError
 
 from dojo.api_v2.serializers import ProductMetaSerializer, TagListSerializerField
-from dojo.authorization.authorization import user_has_permission
-from dojo.authorization.roles_permissions import Permissions
 from dojo.models import (
     Dojo_User,
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
 )
 from dojo.organization.api.serializers import RelatedOrganizationField
 from dojo.product.queries import get_authorized_products
@@ -17,7 +12,7 @@
 
 class RelatedAssetField(serializers.PrimaryKeyRelatedField):
     def get_queryset(self):
-        return get_authorized_products(Permissions.Product_View)
+        return get_authorized_products("view")
 
 
 class AssetAPIScanConfigurationSerializer(serializers.ModelSerializer):
@@ -78,89 +73,3 @@ def get_findings_count(self, obj) -> int:
     # TODO: maybe extend_schema_field is needed here?
     def get_findings_list(self, obj) -> list[int]:
         return obj.open_findings_list()
-
-
-class AssetMemberSerializer(serializers.ModelSerializer):
-    asset = RelatedAssetField(source="product")
-
-    class Meta:
-        model = Product_Member
-        exclude = ("product",)
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("asset") != self.instance.product
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("asset"),
-                Permissions.Product_Manage_Members,
-            )
-        ):
-            msg = "You are not permitted to add a member to this Asset"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("asset") != self.instance.product
-            or data.get("user") != self.instance.user
-        ):
-            members = Product_Member.objects.filter(
-                product=data.get("asset"), user=data.get("user"),
-            )
-            if members.count() > 0:
-                msg = "Asset Member already exists"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("asset"),
-            Permissions.Product_Member_Add_Owner,
-        ):
-            msg = "You are not permitted to add a member as Owner to this Asset"
-            raise PermissionDenied(msg)
-
-        return data
-
-
-class AssetGroupSerializer(serializers.ModelSerializer):
-    asset = RelatedAssetField(source="product")
-
-    class Meta:
-        model = Product_Group
-        exclude = ("product",)
-
-    def validate(self, data):
-        if (
-            self.instance is not None
-            and data.get("asset") != self.instance.product
-            and not user_has_permission(
-                self.context["request"].user,
-                data.get("asset"),
-                Permissions.Product_Group_Add,
-            )
-        ):
-            msg = "You are not permitted to add a group to this Asset"
-            raise PermissionDenied(msg)
-
-        if (
-            self.instance is None
-            or data.get("asset") != self.instance.product
-            or data.get("group") != self.instance.group
-        ):
-            members = Product_Group.objects.filter(
-                product=data.get("asset"), group=data.get("group"),
-            )
-            if members.count() > 0:
-                msg = "Asset Group already exists"
-                raise ValidationError(msg)
-
-        if data.get("role").is_owner and not user_has_permission(
-            self.context["request"].user,
-            data.get("asset"),
-            Permissions.Product_Group_Add_Owner,
-        ):
-            msg = "You are not permitted to add a group as Owner to this Asset"
-            raise PermissionDenied(msg)
-
-        return data
diff --git a/dojo/asset/api/urls.py b/dojo/asset/api/urls.py
index 706996ea27e..535a80a98c8 100644
--- a/dojo/asset/api/urls.py
+++ b/dojo/asset/api/urls.py
@@ -1,7 +1,5 @@
 from dojo.asset.api.views import (
     AssetAPIScanConfigurationViewSet,
-    AssetGroupViewSet,
-    AssetMemberViewSet,
     AssetViewSet,
 )
 
@@ -10,6 +8,6 @@ def add_asset_urls(router):
     router.register(r"assets", AssetViewSet, basename="asset")
     router.register(r"asset_api_scan_configurations", AssetAPIScanConfigurationViewSet,
                     basename="asset_api_scan_configuration")
-    router.register(r"asset_groups", AssetGroupViewSet, basename="asset_group")
-    router.register(r"asset_members", AssetMemberViewSet, basename="asset_member")
+    # RBAC alias endpoints moved to Pro under legacy authorization:
+    #   asset_groups, asset_members → pro/product_groups, pro/product_members
     return router
diff --git a/dojo/asset/api/views.py b/dojo/asset/api/views.py
index 0e01499c466..47162e1792d 100644
--- a/dojo/asset/api/views.py
+++ b/dojo/asset/api/views.py
@@ -6,27 +6,21 @@
 from rest_framework.response import Response
 
 import dojo.api_v2.mixins as dojo_mixins
-from dojo.api_v2 import permissions, prefetch
+from dojo.api_v2 import prefetch
 from dojo.api_v2.serializers import ReportGenerateOptionSerializer, ReportGenerateSerializer
 from dojo.api_v2.views import PrefetchDojoModelViewSet, report_generate, schema_with_prefetch
 from dojo.asset.api import serializers
 from dojo.asset.api.filters import (
     ApiAssetFilter,
     AssetAPIScanConfigurationFilterSet,
-    AssetGroupFilterSet,
-    AssetMemberFilterSet,
 )
-from dojo.authorization.roles_permissions import Permissions
+from dojo.authorization import api_permissions as permissions
 from dojo.models import (
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
 )
 from dojo.product.queries import (
     get_authorized_product_api_scan_configurations,
-    get_authorized_product_groups,
-    get_authorized_product_members,
     get_authorized_products,
 )
 from dojo.utils import async_delete, get_setting
@@ -48,7 +42,7 @@ class AssetAPIScanConfigurationViewSet(
 
     def get_queryset(self):
         return get_authorized_product_api_scan_configurations(
-            Permissions.Product_API_Scan_Configuration_View,
+            "view",
         )
 
 
@@ -72,7 +66,7 @@ class AssetViewSet(
     )
 
     def get_queryset(self):
-        return get_authorized_products(Permissions.Product_View).distinct()
+        return get_authorized_products("view").distinct()
 
     def destroy(self, request, *args, **kwargs):
         instance = self.get_object()
@@ -125,59 +119,3 @@ def generate_report(self, request, pk=None):
         data = report_generate(request, product, options)
         report = ReportGenerateSerializer(data)
         return Response(report.data)
-
-
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class AssetMemberViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.AssetMemberSerializer
-    queryset = Product_Member.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_class = AssetMemberFilterSet
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasAssetMemberPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_product_members(
-            Permissions.Product_View,
-        ).distinct()
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
-
-
-# Authorization: object-based
-@extend_schema_view(**schema_with_prefetch())
-class AssetGroupViewSet(
-    PrefetchDojoModelViewSet,
-):
-    serializer_class = serializers.AssetGroupSerializer
-    queryset = Product_Group.objects.none()
-    filter_backends = (DjangoFilterBackend,)
-    filterset_class = AssetGroupFilterSet
-    permission_classes = (
-        IsAuthenticated,
-        permissions.UserHasAssetGroupPermission,
-    )
-
-    def get_queryset(self):
-        return get_authorized_product_groups(
-            Permissions.Product_Group_View,
-        ).distinct()
-
-    @extend_schema(
-        exclude=True,
-    )
-    def partial_update(self, request, pk=None):
-        # Object authorization won't work if not all data is provided
-        response = {"message": "Patch function is not offered in this path."}
-        return Response(response, status=status.HTTP_405_METHOD_NOT_ALLOWED)
diff --git a/dojo/asset/urls.py b/dojo/asset/urls.py
index 24d369b4410..3f4c5019fcc 100644
--- a/dojo/asset/urls.py
+++ b/dojo/asset/urls.py
@@ -123,21 +123,6 @@
             views.delete_engagement_presets,
             name="delete_engagement_presets",
         ),
-        re_path(
-            r"^asset/(?P\d+)/add_member$",
-            views.add_product_member,
-            name="add_product_member",
-        ),
-        re_path(
-            r"^asset/member/(?P\d+)/edit$",
-            views.edit_product_member,
-            name="edit_product_member",
-        ),
-        re_path(
-            r"^asset/member/(?P\d+)/delete$",
-            views.delete_product_member,
-            name="delete_product_member",
-        ),
         re_path(
             r"^asset/(?P\d+)/add_api_scan_configuration$",
             views.add_api_scan_configuration,
@@ -158,21 +143,6 @@
             views.delete_api_scan_configuration,
             name="delete_api_scan_configuration",
         ),
-        re_path(
-            r"^asset/(?P\d+)/add_group$",
-            views.add_product_group,
-            name="add_product_group",
-        ),
-        re_path(
-            r"^asset/group/(?P\d+)/edit$",
-            views.edit_product_group,
-            name="edit_product_group",
-        ),
-        re_path(
-            r"^asset/group/(?P\d+)/delete$",
-            views.delete_product_group,
-            name="delete_product_group",
-        ),
         # TODO: Backwards compatibility; remove after v3 migration is complete
         re_path(r"^product$", redirect_view("product")),
         re_path(r"^product/(?P\d+)$", redirect_view("view_product")),
@@ -195,16 +165,10 @@
         re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/edit$", redirect_view("edit_engagement_presets")),
         re_path(r"^product/(?P\d+)/engagement_presets/add$", redirect_view("add_engagement_presets")),
         re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/delete$", redirect_view("delete_engagement_presets")),
-        re_path(r"^product/(?P\d+)/add_member$", redirect_view("add_product_member")),
-        re_path(r"^product/member/(?P\d+)/edit$", redirect_view("edit_product_member")),
-        re_path(r"^product/member/(?P\d+)/delete$", redirect_view("delete_product_member")),
         re_path(r"^product/(?P\d+)/add_api_scan_configuration$", redirect_view("add_api_scan_configuration")),
         re_path(r"^product/(?P\d+)/view_api_scan_configurations$", redirect_view("view_api_scan_configurations")),
         re_path(r"^product/(?P\d+)/edit_api_scan_configuration/(?P\d+)$", redirect_view("edit_api_scan_configuration")),
         re_path(r"^product/(?P\d+)/delete_api_scan_configuration/(?P\d+)$", redirect_view("delete_api_scan_configuration")),
-        re_path(r"^product/(?P\d+)/add_group$", redirect_view("add_product_group")),
-        re_path(r"^product/group/(?P\d+)/edit$", redirect_view("edit_product_group")),
-        re_path(r"^product/group/(?P\d+)/delete$", redirect_view("delete_product_group")),
     ]
 else:
     urlpatterns = [
@@ -257,12 +221,12 @@
                 name="add_engagement_presets"),
         re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/delete$", views.delete_engagement_presets,
                 name="delete_engagement_presets"),
-        re_path(r"^product/(?P\d+)/add_member$", views.add_product_member,
-                name="add_product_member"),
-        re_path(r"^product/member/(?P\d+)/edit$", views.edit_product_member,
-                name="edit_product_member"),
-        re_path(r"^product/member/(?P\d+)/delete$", views.delete_product_member,
-                name="delete_product_member"),
+        re_path(r"^product/(?P\d+)/authorized_users/add$",
+                views.add_product_authorized_users,
+                name="add_product_authorized_users"),
+        re_path(r"^product/(?P\d+)/authorized_users/(?P\d+)/delete$",
+                views.delete_product_authorized_user,
+                name="delete_product_authorized_user"),
         re_path(r"^product/(?P\d+)/add_api_scan_configuration$", views.add_api_scan_configuration,
                 name="add_api_scan_configuration"),
         re_path(r"^product/(?P\d+)/view_api_scan_configurations$", views.view_api_scan_configurations,
@@ -273,12 +237,6 @@
         re_path(r"^product/(?P\d+)/delete_api_scan_configuration/(?P\d+)$",
                 views.delete_api_scan_configuration,
                 name="delete_api_scan_configuration"),
-        re_path(r"^product/(?P\d+)/add_group$", views.add_product_group,
-                name="add_product_group"),
-        re_path(r"^product/group/(?P\d+)/edit$", views.edit_product_group,
-                name="edit_product_group"),
-        re_path(r"^product/group/(?P\d+)/delete$", views.delete_product_group,
-                name="delete_product_group"),
         # Forward compatibility
         re_path(r"^asset$", redirect_view("product")),
         re_path(r"^asset/(?P\d+)$", redirect_view("view_product")),
@@ -302,16 +260,10 @@
         re_path(r"^asset/(?P\d+)/engagement_presets/add$", redirect_view("add_engagement_presets")),
         re_path(r"^asset/(?P\d+)/engagement_presets/(?P\d+)/delete$",
                 redirect_view("delete_engagement_presets")),
-        re_path(r"^asset/(?P\d+)/add_member$", redirect_view("add_product_member")),
-        re_path(r"^asset/member/(?P\d+)/edit$", redirect_view("edit_product_member")),
-        re_path(r"^asset/member/(?P\d+)/delete$", redirect_view("delete_product_member")),
         re_path(r"^asset/(?P\d+)/add_api_scan_configuration$", redirect_view("add_api_scan_configuration")),
         re_path(r"^asset/(?P\d+)/view_api_scan_configurations$", redirect_view("view_api_scan_configurations")),
         re_path(r"^asset/(?P\d+)/edit_api_scan_configuration/(?P\d+)$",
                 redirect_view("edit_api_scan_configuration")),
         re_path(r"^asset/(?P\d+)/delete_api_scan_configuration/(?P\d+)$",
                 redirect_view("delete_api_scan_configuration")),
-        re_path(r"^asset/(?P\d+)/add_group$", redirect_view("add_product_group")),
-        re_path(r"^asset/group/(?P\d+)/edit$", redirect_view("edit_product_group")),
-        re_path(r"^asset/group/(?P\d+)/delete$", redirect_view("delete_product_group")),
     ]
diff --git a/dojo/auditlog/filters.py b/dojo/auditlog/filters.py
index 6fa93e6bcd3..dad48774eca 100644
--- a/dojo/auditlog/filters.py
+++ b/dojo/auditlog/filters.py
@@ -9,7 +9,6 @@
 )
 from django_filters.filters import ChoiceFilter
 
-from dojo.authorization.roles_permissions import Permissions
 from dojo.filters import DateRangeFilter, DojoFilter
 from dojo.models import Dojo_User
 from dojo.user.queries import get_authorized_users
@@ -23,7 +22,7 @@ class LogEntryFilter(DojoFilter):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.form.fields["actor"].queryset = get_authorized_users(Permissions.Product_View)
+        self.form.fields["actor"].queryset = get_authorized_users("view")
 
     class Meta:
         model = LogEntry
@@ -87,7 +86,7 @@ class PgHistoryFilter(DojoFilter):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.form.fields["user"].queryset = get_authorized_users(Permissions.Product_View)
+        self.form.fields["user"].queryset = get_authorized_users("view")
 
     def filter_pgh_diff_contains(self, queryset, name, value):
         """
diff --git a/dojo/auditlog/ui/views.py b/dojo/auditlog/ui/views.py
index 7d1773acf45..7bce9acb760 100644
--- a/dojo/auditlog/ui/views.py
+++ b/dojo/auditlog/ui/views.py
@@ -18,7 +18,6 @@
     user_has_permission,
     user_has_permission_or_403,
 )
-from dojo.authorization.roles_permissions import Permissions
 from dojo.location.models import Location
 from dojo.models import (
     Endpoint,
@@ -46,34 +45,34 @@ def action_history(request, cid, oid):
     object_value = None
 
     if ct.model == "product":
-        user_has_permission_or_403(request.user, obj, Permissions.Product_View)
+        user_has_permission_or_403(request.user, obj, "view")
         product_id = obj.id
         active_tab = "overview"
         object_value = Product.objects.get(id=obj.id)
     elif ct.model == "engagement":
-        user_has_permission_or_403(request.user, obj, Permissions.Engagement_View)
+        user_has_permission_or_403(request.user, obj, "view")
         object_value = Engagement.objects.get(id=obj.id)
         product_id = object_value.product.id
         active_tab = "engagements"
     elif ct.model == "test":
-        user_has_permission_or_403(request.user, obj, Permissions.Test_View)
+        user_has_permission_or_403(request.user, obj, "view")
         object_value = Test.objects.get(id=obj.id)
         product_id = object_value.engagement.product.id
         active_tab = "engagements"
         test = True
     elif ct.model == "finding":
-        user_has_permission_or_403(request.user, obj, Permissions.Finding_View)
+        user_has_permission_or_403(request.user, obj, "view")
         object_value = Finding.objects.get(id=obj.id)
         product_id = object_value.test.engagement.product.id
         active_tab = "findings"
         finding = object_value
     elif ct.model == "location":
-        user_has_permission_or_403(request.user, obj, Permissions.Location_View)
+        user_has_permission_or_403(request.user, obj, "view")
         object_value = Location.objects.get(id=obj.id)
         active_tab = "endpoints"
     # TODO: Delete this after the move to Locations
     elif ct.model == "endpoint":
-        user_has_permission_or_403(request.user, obj, Permissions.Location_View)
+        user_has_permission_or_403(request.user, obj, "view")
         object_value = Endpoint.objects.get(id=obj.id)
         product_id = object_value.product.id
         active_tab = "endpoints"
@@ -82,10 +81,10 @@ def action_history(request, cid, oid):
         authorized = False
         fetched_engagements = list(engagements)
         if len(fetched_engagements) == 0:
-            authorized = user_has_global_permission(request.user, Permissions.Risk_Acceptance)
+            authorized = user_has_global_permission(request.user, "edit")
         else:
             for engagement in fetched_engagements:
-                if user_has_permission(request.user, engagement, Permissions.Engagement_View):
+                if user_has_permission(request.user, engagement, "view"):
                     authorized = True
                     break
         if not authorized:
diff --git a/dojo/authorization/MIGRATION_REHEARSAL.md b/dojo/authorization/MIGRATION_REHEARSAL.md
new file mode 100644
index 00000000000..2fa12a9812b
--- /dev/null
+++ b/dojo/authorization/MIGRATION_REHEARSAL.md
@@ -0,0 +1,247 @@
+# Legacy Authorization Migration Rehearsal
+
+End-to-end verification procedure for the Track B legacy authorization
+rewrite. Run on a representative DB snapshot before merging the upstream
+PR; re-run on each customer-shaped snapshot before promoting Pro releases.
+
+The four scenarios below cover every realistic upgrade path. Scenarios 1
+and 3 are runnable inside the regular Pro Docker stack with the bare_bones
+fixture; scenarios 2 and 4 require a different topology (OS-only
+deployment / fresh DB) that the docker-compose dev environment does not
+expose by default.
+
+## Pre-rehearsal: known-good DB snapshot
+
+Before running any of the scenarios below, capture two DB snapshots:
+
+```bash
+docker exec postgres pg_dump -U postgres -Fc dojodb \
+    > snapshots/pre-migration.dump
+```
+
+You will also need:
+
+* a Pro snapshot taken **before** Track B migrations land (RBAC active)
+* an OS-only snapshot taken **before** Track B migrations land (so it
+  has the dojo_* RBAC tables but no Pro app state)
+
+---
+
+## Scenario 1 — Pro path (transparent upgrade)
+
+**Setup**: Pro snapshot. Run Track B migrations (dojo.0266 → dojo.0267
+→ pro.0049 → dojo.0268).
+
+**Expected**: state-only ownership transfer; no row changes; Pro RBAC
+behaves identically to pre-migration.
+
+**Procedure**:
+
+```bash
+# Restore Pro snapshot, run migrate
+./dojoctl up
+docker exec dojo python manage.py migrate
+
+# Verify counts unchanged
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT 'auth_role' AS t, count(*) FROM dojo_role
+UNION ALL SELECT 'global_role', count(*) FROM dojo_global_role
+UNION ALL SELECT 'product_member', count(*) FROM dojo_product_member
+UNION ALL SELECT 'product_type_member', count(*) FROM dojo_product_type_member
+UNION ALL SELECT 'product_group', count(*) FROM dojo_product_group
+UNION ALL SELECT 'product_type_group', count(*) FROM dojo_product_type_group
+UNION ALL SELECT 'dojo_group_member', count(*) FROM dojo_dojo_group_member;
+"
+# Compare against pre-migration counts — should be identical.
+
+# Verify Pro can still query
+docker exec dojo python manage.py shell -c "
+from pro.authorization.models import Role, Product_Member, Global_Role
+print('Pro Role count:', Role.objects.count())
+print('Pro Product_Member count:', Product_Member.objects.count())
+print('Pro Global_Role count:', Global_Role.objects.count())
+"
+
+# Run Pro test suite
+docker exec dojo pytest /app/dojo-pro/unit_tests/ -q
+```
+
+**Verified in CI environment (bare_bones fixture)**:
+- 5 Roles, 4 Global_Roles, 1 Product_Member, 1 Product_Type_Member,
+  1 Product_Group, 1 Product_Type_Group, 3 Dojo_Group_Members preserved.
+- Pro queries via `pro.authorization.models` return the same counts.
+- 442 tests across pro/authorization, pro/api/authorization,
+  pro/api_helpers, dashboard/test_views_extended pass.
+
+---
+
+## Scenario 2 — OS-standalone upgrade (the legacy rewrite)
+
+**Setup**: OS-only snapshot (Pro NOT installed). Run Track B migrations
+on a deployment that does not include the Pro app.
+
+**Expected**:
+- `dojo.0266` adds `authorized_users` M2M field.
+- `dojo.0267` backfills authorized_users from RBAC tables (dormant in
+  this scenario but populated from the OS-only customer's prior data).
+- `dojo.0268` flips RBAC models to managed=False in dojo state.
+- Customers continue to use the system; per-product role granularity
+  collapses to membership (legacy semantics).
+
+**Procedure**:
+
+```bash
+# 1. Run the preview command first to audit impact
+docker exec dojo python manage.py preview_legacy_authorization_migration --json \
+    > pre-upgrade-preview.json
+
+# 2. Apply migrations
+docker exec dojo python manage.py migrate
+
+# 3. Verify authorized_users populated
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT 'product authorized_users' AS t, count(*) FROM dojo_product_authorized_users
+UNION ALL SELECT 'product_type authorized_users', count(*) FROM dojo_product_type_authorized_users;
+"
+
+# 4. Verify RBAC tables intact (not touched by the migration)
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT count(*) FROM dojo_role;
+SELECT count(*) FROM dojo_global_role;
+"
+# These should match pre-migration counts.
+
+# 5. Verify is_superuser / is_staff flips for users with elevated Global_Roles
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT username, is_superuser, is_staff FROM auth_user
+WHERE is_superuser OR is_staff
+ORDER BY id;
+"
+
+# 6. Run OS test suite (failures are expected — see "OS test fallout" below)
+docker exec dojo python manage.py test dojo
+```
+
+**Status in this environment**: Not directly runnable (the bare_bones
+Pro stack always loads Pro). The migration's logic was unit-tested
+against bare_bones data in this environment with Pro present (Pro's
+shadow is harmless to data migrations); the actual scenario must be
+verified on an OS-only deployment.
+
+**Known OS test fallout**: tests in `dojo/tests/` and
+`unittests/authorization/` that assert RBAC role hierarchy ("Reader can
+view but not edit") will fail on the legacy rewrite — they describe the
+old RBAC contract. Update them to assert membership-based legacy
+semantics in a follow-up change.
+
+---
+
+## Scenario 3 — OS → Pro reinstall (reconcile gotcha)
+
+**Setup**: Customer ran Scenario 2, then made per-product changes via
+the OS UI (adds/removes in `Product.authorized_users`). Now they install
+a Pro license.
+
+**Expected**: Pro adopts the existing RBAC tables but those tables are
+stuck at the pre-Scenario-2 snapshot. The reconcile command brings
+Product_Member rows back in sync with authorized_users.
+
+**Procedure**:
+
+```bash
+# 1. Install Pro, run migrate (state-only)
+docker exec dojo python manage.py migrate
+
+# 2. Run reconcile in dry-run mode first
+docker exec dojo python manage.py reconcile_authorized_users_to_rbac --dry-run
+
+# 3. Apply
+docker exec dojo python manage.py reconcile_authorized_users_to_rbac --role Writer
+
+# 4. Verify Product_Member now matches authorized_users
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT
+    (SELECT count(*) FROM dojo_product_authorized_users) AS au_pairs,
+    (SELECT count(*) FROM dojo_product_member) AS pm_rows,
+    (SELECT count(*) FROM dojo_product_type_authorized_users) AS au_pt_pairs,
+    (SELECT count(*) FROM dojo_product_type_member) AS ptm_rows;
+"
+# pm_rows >= au_pairs after reconcile (>= because direct-RBAC-only
+# Product_Member rows that have no corresponding authorized_users entry
+# still exist).
+
+# 5. Re-run reconcile — should be a no-op
+docker exec dojo python manage.py reconcile_authorized_users_to_rbac
+# Output: "Already reconciled — nothing to do."
+```
+
+**Verified in CI environment**:
+- `--dry-run` reports 1 Product_Member + 1 Product_Type_Member to create
+  from the 2-pair authorized_users state (1 was a direct member already,
+  1 was added from group expansion during 0267 backfill).
+- Idempotent: re-running after apply prints "Already reconciled".
+
+---
+
+## Scenario 4 — Fresh OS install (no-op for the legacy migration)
+
+**Setup**: Brand-new database. No tables exist before `migrate`.
+
+**Expected**: 0266 schema-creates `authorized_users` M2M. 0267 detects
+no `dojo_role` table and early-returns (no-op). 0268 flips state for
+models that were just created by older migrations.
+
+**Procedure**:
+
+```bash
+# 1. Drop the database and recreate
+docker exec postgres dropdb -U postgres dojodb
+docker exec postgres createdb -U postgres dojodb
+
+# 2. Run all migrations
+docker exec dojo python manage.py migrate
+
+# 3. Verify migrations applied with no errors
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT app, name FROM django_migrations
+WHERE name LIKE '%authorized%' OR name LIKE '%rbac%'
+ORDER BY id;
+"
+# Expected: 0266_reintroduce_authorized_users, 0267_backfill_authorized_users,
+#           0268_release_rbac_state, plus pro.0049_adopt_rbac_tables (if Pro).
+
+# 4. Verify authorized_users M2M tables are empty (fresh install)
+docker exec postgres psql -U postgres -d dojodb -c "
+SELECT count(*) FROM dojo_product_authorized_users;
+SELECT count(*) FROM dojo_product_type_authorized_users;
+"
+# Both 0.
+```
+
+**Status in this environment**: Not directly runnable without dropping
+and recreating the dojodb. The `dojo_role` introspection guard in 0267
+was code-reviewed and verified against the introspection result on
+this environment (`dojo_role` is present here, so guard does NOT
+short-circuit — but the inverse case is the well-defined fallthrough).
+
+---
+
+## Release-notes blueprint (per scenario)
+
+Each upgrade scenario maps to its own customer-facing message:
+
+| Scenario | Release-notes section title |
+|----------|----------------------------|
+| 1 | "Pro upgrade is transparent — no permission semantics change" |
+| 2 | "Legacy authorization migration: what your users can do now" |
+| 3 | "Re-installing Pro after an OS-only window: run reconcile" |
+| 4 | "Fresh OS installs: no-op" |
+
+Scenario 2 is the longest and most important. Required content:
+
+* The role-flattening table from `permission_to_action()` (Reader/Writer/
+  Maintainer/Owner all collapse to "authorized")
+* SQL or `preview_legacy_authorization_migration` example so customers
+  can audit before upgrading
+* Statement that historical RBAC data is preserved (no rows dropped)
+* Pointer to dojo-pro for customers who need RBAC fidelity back
diff --git a/dojo/authorization/__init__.py b/dojo/authorization/__init__.py
index e69de29bb2d..5f0c80bfc7b 100644
--- a/dojo/authorization/__init__.py
+++ b/dojo/authorization/__init__.py
@@ -0,0 +1,7 @@
+# NOTE: do not import query_registrations here. It pulls in dojo.models
+# (and dojo.location.models), which can be mid-import when this package
+# is loaded transitively (e.g. via `from dojo.authorization.query_filters
+# import get_auth_filter`). If that chain raises ImportError, callers
+# silently fall back to a stub `get_auth_filter` and the queryset auth
+# filters never apply. Registration is now triggered explicitly in
+# dojo/apps.py ready() once all models are loaded.
diff --git a/dojo/authorization/api_permissions.py b/dojo/authorization/api_permissions.py
new file mode 100644
index 00000000000..d8237c382f0
--- /dev/null
+++ b/dojo/authorization/api_permissions.py
@@ -0,0 +1,1120 @@
+
+from django.conf import settings
+from django.db.models import Model
+from django.shortcuts import get_object_or_404
+from rest_framework import permissions, serializers
+from rest_framework.exceptions import (
+    ParseError,
+    PermissionDenied,
+    ValidationError,
+)
+from rest_framework.request import Request
+
+from dojo.authorization.authorization import (
+    user_has_configuration_permission,
+    user_has_global_permission,
+    user_has_permission,
+    user_is_superuser_or_global_owner,
+)
+from dojo.importers.auto_create_context import AutoCreateContextManager
+from dojo.location.models import Location
+from dojo.models import (
+    Development_Environment,
+    Endpoint,
+    Engagement,
+    Finding,
+    Finding_Group,
+    Product,
+    Product_Type,
+    Regulation,
+    SLA_Configuration,
+    Test,
+)
+
+
+def check_post_permission(request: Request, post_model: Model, post_pk: str | list[str], post_permission: int) -> bool:
+    if request.method == "POST":
+        if request.data.get(post_pk) is None:
+            msg = f"Unable to check for permissions: Attribute '{post_pk}' is required"
+            raise ParseError(msg)
+        obj = get_object_or_404(post_model, pk=request.data.get(post_pk))
+        return user_has_permission(request.user, obj, post_permission)
+    return True
+
+
+def check_object_permission(
+    request: Request,
+    obj: Model,
+    get_permission: int,
+    put_permission: int,
+    delete_permission: int,
+    post_permission: int | None = None,
+) -> bool:
+    if request.method == "GET":
+        return user_has_permission(request.user, obj, get_permission)
+    if request.method in {"PUT", "PATCH"}:
+        return user_has_permission(request.user, obj, put_permission)
+    if request.method == "DELETE":
+        return user_has_permission(request.user, obj, delete_permission)
+    if request.method == "POST":
+        return user_has_permission(request.user, obj, post_permission)
+    return False
+
+
+class BaseRelatedObjectPermission(permissions.BasePermission):
+
+    """
+    An "abstract" base class for related object permissions (like notes, metadata, etc.)
+    that only need object permissions, not general permissions. This class will serve as
+    the base class for other more aptly named permission classes.
+    """
+
+    permission_map: dict[str, int] = {
+        "get_permission": None,
+        "put_permission": None,
+        "delete_permission": None,
+        "post_permission": None,
+    }
+
+    def has_permission(self, request: Request, view):
+        # related object only need object permission
+        return True
+
+    def has_object_permission(self, request: Request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            **self.permission_map,
+        )
+
+
+class BaseDjangoModelPermission(permissions.BasePermission):
+
+    """
+    An "abstract" base class for Django model permissions.
+    This class will serve as the base class for other more aptly named permission classes.
+    """
+
+    django_model: Model = None
+    request_method_permission_map: dict[str, str] = {
+        "GET": "view",
+        "POST": "add",
+        "PUT": "change",
+        "PATCH": "change",
+        "DELETE": "delete",
+    }
+
+    def _evaluate_permissions(self, request: Request, permissions: dict[str, str]) -> bool:
+        # Short circuit if the request method is not in the expected methods
+        if request.method not in permissions:
+            return True
+        # Evaluate the permissions as usual
+        for method, permission in permissions.items():
+            if request.method == method:
+                return user_has_configuration_permission(
+                    request.user,
+                    f"{self.django_model._meta.app_label}.{permission}_{self.django_model._meta.model_name}",
+                )
+        return False
+
+    def has_permission(self, request: Request, view):
+        # First restrict the mapping got GET/POST only
+        expected_request_method_permission_map = {k: v for k, v in self.request_method_permission_map.items() if k in {"GET", "POST"}}
+        # Evaluate the permissions
+        return self._evaluate_permissions(request, expected_request_method_permission_map)
+
+    def has_object_permission(self, request: Request, view, obj):
+        return self._evaluate_permissions(request, self.request_method_permission_map)
+
+
+class UserHasAppAnalysisPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Product, "product", "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.product,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasDojoMetaPermission(permissions.BasePermission):
+    permission_map = {
+        "product": {
+            "model": Product,
+            "permissions": {
+                "get_permission": "view",
+                "put_permission": "edit",
+                "delete_permission": "edit",
+                "post_permission": "edit",
+            },
+        },
+        "finding": {
+            "model": Finding,
+            "permissions": {
+                "get_permission": "view",
+                "put_permission": "edit",
+                "delete_permission": "edit",
+                "post_permission": "edit",
+            },
+        },
+        "location": {
+            "model": Location,
+            "permissions": {
+                "get_permission": "view",
+                "put_permission": "edit",
+                "delete_permission": "edit",
+                "post_permission": "edit",
+            },
+        },
+        # TODO: Delete this after the move to Locations
+        "endpoint": {
+            "model": Endpoint if not settings.V3_FEATURE_LOCATIONS else Location,
+            "permissions": {
+                "get_permission": "view",
+                "put_permission": "edit",
+                "delete_permission": "edit",
+                "post_permission": "edit",
+            },
+        },
+    }
+
+    def has_permission(self, request, view):
+        method_to_permission_map = {
+            "GET": "get_permission",
+            "POST": "post_permission",
+            # PATCH is generally not used here, but this endpoint is sorta odd...
+            "PATCH": "put_permission",
+        }
+        for request_method, permission_type in method_to_permission_map.items():
+            if request.method == request_method:
+                has_permission_result = True
+                for model_field, schema in self.permission_map.items():
+                    if (object_id := request.data.get(model_field)) is not None:
+                        obj = get_object_or_404(
+                            schema["model"],
+                            pk=object_id,
+                        )
+                        has_permission_result = (
+                            has_permission_result
+                            and user_has_permission(
+                                request.user,
+                                obj,
+                                schema["permissions"][permission_type],
+                            )
+                        )
+                return has_permission_result
+        # If we exit the loop at some point, we must not checking perms for that request method
+        return True
+
+    def has_object_permission(self, request, view, obj):
+        has_permission_result = True
+        for model_field, schema in self.permission_map.items():
+            if (object_model := getattr(obj, model_field, None)) is not None:
+                has_permission_result = (
+                has_permission_result
+                and check_object_permission(
+                    request,
+                    object_model,
+                    **schema["permissions"],
+                )
+            )
+
+        return has_permission_result
+
+
+class UserHasToolProductSettingsPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Product, "product", "edit",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.product,
+            "view",
+            "edit",
+            "edit",
+        )
+
+
+# TODO: Delete this after the move to Locations
+class UserHasEndpointPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Product, "product", "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+# TODO: Delete this after the move to Locations
+class UserHasEndpointStatusPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Endpoint, "endpoint", "edit",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.endpoint,
+            "view",
+            "edit",
+            "edit",
+        )
+
+
+class UserHasEngagementPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+                request, Product, "product", "add",
+            )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasEngagementRelatedObjectPermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "view",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "edit",
+    }
+
+
+class UserHasEngagementNotePermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "view",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "view",
+    }
+
+
+class UserHasRiskAcceptancePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        # The previous implementation only checked for the object permission if the path was
+        # /api/v2/risk_acceptances/, but the path has always been /api/v2/risk_acceptance/ (notice the missing "s")
+        # So there really has not been a notion of a post permission check for risk acceptances.
+        # It would be best to leave as is to not break any existing implementations.
+        return True
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "edit",
+            "edit",
+            "edit",
+        )
+
+
+class UserHasRiskAcceptanceRelatedObjectPermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "edit",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "edit",
+    }
+
+
+class UserHasFindingPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Test, "test", "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasFindingRelatedObjectPermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "view",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "edit",
+    }
+
+
+class UserHasFindingNotePermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "view",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "view",
+    }
+
+
+class UserHasBurpRawRequestResponsePermission(permissions.BasePermission):
+
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Finding, "finding", "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.finding,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasImportPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        # permission check takes place before validation, so we don't have access to serializer.validated_data()
+        # and we have to validate ourselves unfortunately
+        auto_create = AutoCreateContextManager()
+        # Process the context to make an conversions needed. Catch any exceptions
+        # in this case and wrap them in a DRF exception
+        try:
+            converted_dict = auto_create.convert_querydict_to_dict(request.data)
+            auto_create.process_import_meta_data_from_dict(converted_dict)
+            # Get an existing product
+            converted_dict["product_type"] = auto_create.get_target_product_type_if_exists(**converted_dict)
+            converted_dict["product"] = auto_create.get_target_product_if_exists(**converted_dict)
+            converted_dict["engagement"] = auto_create.get_target_engagement_if_exists(**converted_dict)
+        except (ValueError, TypeError) as e:
+            # Raise an explicit drf exception here
+            raise ValidationError(e)
+        if engagement := converted_dict.get("engagement"):
+            # Validate the resolved engagement's parent chain matches any provided identifiers
+            if (product := converted_dict.get("product")) and engagement.product_id != product.id:
+                msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
+                raise ValidationError(msg)
+            if (engagement_name := converted_dict.get("engagement_name")) and engagement.name != engagement_name:
+                msg = "The provided identifiers are inconsistent — the engagement name does not match the specified engagement."
+                raise ValidationError(msg)
+            return user_has_permission(
+                request.user, engagement, "import",
+            )
+        if engagement_id := converted_dict.get("engagement_id"):
+            # engagement_id doesn't exist
+            msg = f'Engagement "{engagement_id}" does not exist'
+            raise serializers.ValidationError(msg)
+
+        if not converted_dict.get("auto_create_context"):
+            raise_no_auto_create_import_validation_error(
+                None,
+                None,
+                converted_dict.get("engagement_name"),
+                converted_dict.get("product_name"),
+                converted_dict.get("product_type_name"),
+                converted_dict.get("engagement"),
+                converted_dict.get("product"),
+                converted_dict.get("product_type"),
+                "Need engagement_id or product_name + engagement_name to perform import",
+            )
+            return None
+        # the engagement doesn't exist, so we need to check if the user has
+        # requested and is allowed to use auto_create
+        return check_auto_create_permission(
+            request.user,
+            converted_dict.get("product"),
+            converted_dict.get("product_name"),
+            converted_dict.get("engagement"),
+            converted_dict.get("engagement_name"),
+            converted_dict.get("product_type"),
+            converted_dict.get("product_type_name"),
+            "Need engagement_id or product_name + engagement_name to perform import",
+        )
+
+
+class UserHasMetaImportPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        # permission check takes place before validation, so we don't have access to serializer.validated_data()
+        # and we have to validate ourselves unfortunately
+        auto_create = AutoCreateContextManager()
+        # Process the context to make an conversions needed. Catch any exceptions
+        # in this case and wrap them in a DRF exception
+        try:
+            converted_dict = auto_create.convert_querydict_to_dict(request.data)
+            auto_create.process_import_meta_data_from_dict(converted_dict)
+            # Get an existing product
+            product = auto_create.get_target_product_if_exists(**converted_dict)
+            if not product:
+                product = auto_create.get_target_product_by_id_if_exists(**converted_dict)
+        except (ValueError, TypeError) as e:
+            # Raise an explicit drf exception here
+            raise ValidationError(e)
+
+        if product:
+            # existing product, nothing special to check
+            return user_has_permission(
+                request.user, product, "import",
+            )
+        if product_id := converted_dict.get("product_id"):
+            # product_id doesn't exist
+            msg = f'Product "{product_id}" does not exist'
+            raise serializers.ValidationError(msg)
+        msg = "Need product_id or product_name to perform import"
+        raise serializers.ValidationError(msg)
+
+
+class UserHasProductPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request,
+            Product_Type,
+            "prod_type",
+            "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasAssetPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request,
+            Product_Type,
+            "organization",
+            "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasProductTypePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        if request.method == "POST":
+            return user_has_global_permission(
+                request.user, "add",
+            )
+        return True
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasOrganizationPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        if request.method == "POST":
+            return user_has_global_permission(
+                request.user, "add",
+            )
+        return True
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasReimportPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        # permission check takes place before validation, so we don't have access to serializer.validated_data()
+        # and we have to validate ourselves unfortunately
+        auto_create = AutoCreateContextManager()
+        # Process the context to make an conversions needed. Catch any exceptions
+        # in this case and wrap them in a DRF exception
+        try:
+            converted_dict = auto_create.convert_querydict_to_dict(request.data)
+            auto_create.process_import_meta_data_from_dict(converted_dict)
+            # engagement is not a declared field on ReImportScanSerializer and will be
+            # stripped during validation — don't use it in the permission check either,
+            # so the permission check resolves targets the same way execution does
+            converted_dict.pop("engagement", None)
+            converted_dict.pop("engagement_id", None)
+            # Get an existing product
+            converted_dict["product_type"] = auto_create.get_target_product_type_if_exists(**converted_dict)
+            converted_dict["product"] = auto_create.get_target_product_if_exists(**converted_dict)
+            converted_dict["engagement"] = auto_create.get_target_engagement_if_exists(**converted_dict)
+            converted_dict["test"] = auto_create.get_target_test_if_exists(**converted_dict)
+        except (ValueError, TypeError) as e:
+            # Raise an explicit drf exception here
+            raise ValidationError(e)
+
+        if test := converted_dict.get("test"):
+            # Validate the resolved test's parent chain matches any provided identifiers
+            if (product := converted_dict.get("product")) and test.engagement.product_id != product.id:
+                msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
+                raise ValidationError(msg)
+            if (engagement := converted_dict.get("engagement")) and test.engagement_id != engagement.id:
+                msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
+                raise ValidationError(msg)
+            # Also validate by name when the objects were not resolved (e.g. names that match no existing record)
+            if not converted_dict.get("product") and (product_name := converted_dict.get("product_name")) and test.engagement.product.name != product_name:
+                msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
+                raise ValidationError(msg)
+            if not converted_dict.get("engagement") and (engagement_name := converted_dict.get("engagement_name")) and test.engagement.name != engagement_name:
+                msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
+                raise ValidationError(msg)
+            return user_has_permission(
+                request.user, test, "import",
+            )
+        if test_id := converted_dict.get("test_id"):
+            # test_id doesn't exist
+            msg = f'Test "{test_id}" does not exist'
+            raise serializers.ValidationError(msg)
+
+        if not converted_dict.get("auto_create_context"):
+            raise_no_auto_create_import_validation_error(
+                converted_dict.get("test_title"),
+                converted_dict.get("scan_type"),
+                converted_dict.get("engagement_name"),
+                converted_dict.get("product_name"),
+                converted_dict.get("product_type_name"),
+                converted_dict.get("engagement"),
+                converted_dict.get("product"),
+                converted_dict.get("product_type"),
+                "Need test_id or product_name + engagement_name + scan_type to perform reimport",
+            )
+            return None
+        # the test doesn't exist, so we need to check if the user has
+        # requested and is allowed to use auto_create
+        return check_auto_create_permission(
+            request.user,
+            converted_dict.get("product"),
+            converted_dict.get("product_name"),
+            converted_dict.get("engagement"),
+            converted_dict.get("engagement_name"),
+            converted_dict.get("product_type"),
+            converted_dict.get("product_type_name"),
+            "Need test_id or product_name + engagement_name + scan_type to perform reimport",
+        )
+
+
+class UserHasTestPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Engagement, "engagement", "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasTestRelatedObjectPermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "view",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "edit",
+    }
+
+
+class UserHasTestNotePermission(BaseRelatedObjectPermission):
+    permission_map = {
+        "get_permission": "view",
+        "put_permission": "edit",
+        "delete_permission": "edit",
+        "post_permission": "view",
+    }
+
+
+class UserHasTestImportPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Test, "test", "edit",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.test,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasLanguagePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Product, "product", "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasProductAPIScanConfigurationPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request,
+            Product,
+            "product",
+            "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasAssetAPIScanConfigurationPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request,
+            Product,
+            "asset",
+            "add",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj,
+            "view",
+            "edit",
+            "delete",
+        )
+
+
+class UserHasJiraProductPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        if request.method == "POST":
+            has_permission_result = True
+            engagement_id = request.data.get("engagement", None)
+            if engagement_id:
+                obj = get_object_or_404(Engagement, pk=engagement_id)
+                has_permission_result = (
+                    has_permission_result
+                    and user_has_permission(
+                        request.user, obj, "edit",
+                    )
+                )
+            product_id = request.data.get("product", None)
+            if product_id:
+                obj = get_object_or_404(Product, pk=product_id)
+                has_permission_result = (
+                    has_permission_result
+                    and user_has_permission(
+                        request.user, obj, "edit",
+                    )
+                )
+            return has_permission_result
+        return True
+
+    def has_object_permission(self, request, view, obj):
+        has_permission_result = True
+        engagement = obj.engagement
+        if engagement:
+            has_permission_result = (
+                has_permission_result
+                and check_object_permission(
+                    request,
+                    engagement,
+                    "view",
+                    "edit",
+                    "edit",
+                )
+            )
+        product = obj.product
+        if product:
+            has_permission_result = (
+                has_permission_result
+                and check_object_permission(
+                    request,
+                    product,
+                    "view",
+                    "edit",
+                    "edit",
+                )
+            )
+        return has_permission_result
+
+
+class UserHasJiraIssuePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        if request.method == "POST":
+            has_permission_result = True
+            engagement_id = request.data.get("engagement", None)
+            if engagement_id:
+                obj = get_object_or_404(Engagement, pk=engagement_id)
+                has_permission_result = (
+                    has_permission_result
+                    and user_has_permission(
+                        request.user, obj, "edit",
+                    )
+                )
+            finding_id = request.data.get("finding", None)
+            if finding_id:
+                obj = get_object_or_404(Finding, pk=finding_id)
+                has_permission_result = (
+                    has_permission_result
+                    and user_has_permission(
+                        request.user, obj, "edit",
+                    )
+                )
+            finding_group_id = request.data.get("finding_group", None)
+            if finding_group_id:
+                obj = get_object_or_404(Finding_Group, pk=finding_group_id)
+                has_permission_result = (
+                    has_permission_result
+                    and user_has_permission(
+                        request.user, obj, "edit",
+                    )
+                )
+            return has_permission_result
+        return True
+
+    def has_object_permission(self, request, view, obj):
+        has_permission_result = True
+        engagement = obj.engagement
+        if engagement:
+            has_permission_result = (
+                has_permission_result
+                and check_object_permission(
+                    request,
+                    engagement,
+                    "view",
+                    "edit",
+                    "edit",
+                )
+            )
+        finding = obj.finding
+        if finding:
+            has_permission_result = (
+                has_permission_result
+                and check_object_permission(
+                    request,
+                    finding,
+                    "view",
+                    "edit",
+                    "edit",
+                )
+            )
+        finding_group = obj.finding_group
+        if finding_group:
+            has_permission_result = (
+                has_permission_result
+                and check_object_permission(
+                    request,
+                    finding_group,
+                    "view",
+                    "edit",
+                    "edit",
+                )
+            )
+        return has_permission_result
+
+
+class IsSuperUser(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return request.user and request.user.is_superuser
+
+
+class IsSuperUserOrGlobalOwner(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return user_is_superuser_or_global_owner(request.user)
+
+
+class UserHasEngagementPresetPermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request, Product, "product", "edit",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.product,
+            "view",
+            "edit",
+            "edit",
+            "edit",
+        )
+
+
+class UserHasSLAPermission(BaseDjangoModelPermission):
+    django_model = SLA_Configuration
+
+
+class UserHasDevelopmentEnvironmentPermission(BaseDjangoModelPermission):
+    django_model = Development_Environment
+    # https://github.com/DefectDojo/django-DefectDojo/blob/963d4a35bfd8f5138330f0d70595a755fa4999b0/dojo/user/utils.py#L93
+    # It looks like view permission was explicitly not supported, so I assume
+    # reading these endpoints are not necessarily restricted (unless you're auth'd of course)
+    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
+    # It looks like view permission was explicitly not supported, so I assume
+    # reading these endpoints are not necessarily restricted (unless you're auth'd of course)
+    request_method_permission_map = {
+        "POST": "add",
+        "PUT": "change",
+        "PATCH": "change",
+        "DELETE": "delete",
+    }
+
+
+def raise_no_auto_create_import_validation_error(
+    test_title,
+    scan_type,
+    engagement_name,
+    product_name,
+    product_type_name,
+    engagement,
+    product,
+    product_type,
+    error_message,
+):
+    # check for mandatory fields first
+    if not product_name:
+        msg = "product_name parameter missing"
+        raise ValidationError(msg)
+
+    if not engagement_name:
+        msg = "engagement_name parameter missing"
+        raise ValidationError(msg)
+
+    if product_type_name and not product_type:
+        msg = f'Product Type "{product_type_name}" does not exist'
+        raise serializers.ValidationError(msg)
+
+    if product_name and not product:
+        if product_type_name:
+            msg = f'Product "{product_name}" does not exist in Product_Type "{product_type_name}"'
+            raise serializers.ValidationError(msg)
+        msg = f'Product "{product_name}" does not exist'
+        raise serializers.ValidationError(msg)
+
+    if engagement_name and not engagement:
+        msg = f'Engagement "{engagement_name}" does not exist in Product "{product_name}"'
+        raise serializers.ValidationError(msg)
+
+    # these are only set for reimport
+    if test_title:
+        msg = f'Test "{test_title}" with scan_type "{scan_type}" does not exist in Engagement "{engagement_name}"'
+        raise serializers.ValidationError(msg)
+
+    if scan_type:
+        msg = f'Test with scan_type "{scan_type}" does not exist in Engagement "{engagement_name}"'
+        raise serializers.ValidationError(msg)
+
+    raise ValidationError(error_message)
+
+
+def check_auto_create_permission(
+    user,
+    product,
+    product_name,
+    engagement,
+    engagement_name,
+    product_type,
+    product_type_name,
+    error_message,
+):
+    """
+    For an existing engagement, to be allowed to import a scan, the following must all be True:
+    - User must have Import_Scan_Result permission for this Engagement
+
+    For an existing product, to be allowed to import into a new engagement with name `engagement_name`, the following must all be True:
+    - Product with name `product_name`  must already exist;
+    - User must have Engagement_Add permission for this Product
+    - User must have Import_Scan_Result permission for this Product
+
+    If the product doesn't exist yet, to be allowed to import into a new product with name `product_name` and prod_type `product_type_name`,
+    the following must all be True:
+    - `auto_create_context` must be True
+    - Product_Type already exists, or the user has the Product_Type_Add permission
+    - User must have Product_Type_Add_Product permission for the Product_Type, or the user has the Product_Type_Add permission
+    """
+    if not product_name:
+        msg = "product_name parameter missing"
+        raise ValidationError(msg)
+
+    if not engagement_name:
+        msg = "engagement_name parameter missing"
+        raise ValidationError(msg)
+
+    if engagement:
+        # Validate the resolved engagement's parent chain matches any provided names
+        if product is not None and engagement.product_id != product.id:
+            msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
+            raise ValidationError(msg)
+        return user_has_permission(
+            user, engagement, "import",
+        )
+
+    if product and product_name and engagement_name:
+        if not user_has_permission(user, product, "add"):
+            msg = f'No permission to create engagements in product "{product_name}"'
+            raise PermissionDenied(msg)
+
+        if not user_has_permission(
+            user, product, "import",
+        ):
+            msg = f'No permission to import scans into product "{product_name}"'
+            raise PermissionDenied(msg)
+
+        # all good
+        return True
+
+    if not product and product_name:
+        if not product_type_name:
+            msg = f'Product "{product_name}" does not exist and no product_type_name provided to create the new product in'
+            raise serializers.ValidationError(msg)
+
+        if not product_type:
+            if not user_has_global_permission(
+                user, "add",
+            ):
+                msg = f'No permission to create product_type "{product_type_name}"'
+                raise PermissionDenied(msg)
+            # new product type can be created with current user as owner, so
+            # all objects in it can be created as well
+            return True
+        if not user_has_permission(
+            user, product_type, "add",
+        ):
+            msg = f'No permission to create products in product_type "{product_type}"'
+            raise PermissionDenied(msg)
+
+        # product can be created, so objects in it can be created as well
+        return True
+
+    raise ValidationError(error_message)
+
+
+class UserHasConfigurationPermissionStaff(permissions.DjangoModelPermissions):
+    # Override map to also provide 'view' permissions
+    perms_map = {
+        "GET": ["%(app_label)s.view_%(model_name)s"],
+        "OPTIONS": [],
+        "HEAD": [],
+        "POST": ["%(app_label)s.add_%(model_name)s"],
+        "PUT": ["%(app_label)s.change_%(model_name)s"],
+        "PATCH": ["%(app_label)s.change_%(model_name)s"],
+        "DELETE": ["%(app_label)s.delete_%(model_name)s"],
+    }
+
+    def has_permission(self, request, view):
+        return super().has_permission(request, view)
+
+
+class UserHasConfigurationPermissionSuperuser(
+    permissions.DjangoModelPermissions,
+):
+    # Override map to also provide 'view' permissions
+    perms_map = {
+        "GET": ["%(app_label)s.view_%(model_name)s"],
+        "OPTIONS": [],
+        "HEAD": [],
+        "POST": ["%(app_label)s.add_%(model_name)s"],
+        "PUT": ["%(app_label)s.change_%(model_name)s"],
+        "PATCH": ["%(app_label)s.change_%(model_name)s"],
+        "DELETE": ["%(app_label)s.delete_%(model_name)s"],
+    }
+
+    def has_permission(self, request, view):
+        return super().has_permission(request, view)
+
+
+class LocationFindingReferencePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request,
+            Finding,
+            "finding",
+            "edit",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.finding,
+            "view",
+            "edit",
+            "edit",
+        )
+
+
+class LocationProductReferencePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return check_post_permission(
+            request,
+            Product,
+            "product",
+            "edit",
+        )
+
+    def has_object_permission(self, request, view, obj):
+        return check_object_permission(
+            request,
+            obj.product,
+            "view",
+            "edit",
+            "edit",
+        )
diff --git a/dojo/authorization/authorization.py b/dojo/authorization/authorization.py
index 0fa5f8723b5..ff50e95f525 100644
--- a/dojo/authorization/authorization.py
+++ b/dojo/authorization/authorization.py
@@ -1,17 +1,38 @@
+"""
+OS authorization: is_superuser / is_staff / authorized_users.
+
+The hierarchical RBAC role system has been moved out of OS to the dojo-pro
+plugin. OS-only deployments authorize an action on an object iff
+
+  * the user is a superuser, or
+  * the user is staff and the action is non-destructive (legacy treats
+    every staff user as eligible for staff-only and delete actions), or
+  * the user is in the relevant ``authorized_users`` ManyToMany
+    (climbing the Product_Type → Product → Engagement → Test → Finding
+    hierarchy until an explicit membership is found).
+
+Per-product role granularity (Reader / Writer / Maintainer / Owner),
+group-level authorization, and Member/Group/Role tables are not consulted
+in this model. Deployments that need that fidelity should run the
+dojo-pro plugin, which keeps the RBAC layer alive and shadows this
+module's symbols at startup so the same code paths route through Pro.
+"""
 from django.core.exceptions import PermissionDenied
-from django.db.models import Model, QuerySet
+from django.db.models import Model
 
+from dojo.authorization.models import Dojo_Group_Member, Product_Member, Product_Type_Member
+from dojo.authorization.query_registrations import (
+    authorized_product_id_set,
+    authorized_product_type_id_set,
+)
 from dojo.authorization.roles_permissions import (
-    Permissions,
-    Roles,
-    get_global_roles_with_permissions,
-    get_roles_with_permissions,
+    Action,
+    permission_to_action,
 )
 from dojo.location.models import AbstractLocation, Location
 from dojo.models import (
     App_Analysis,
     Dojo_Group,
-    Dojo_Group_Member,
     Dojo_User,
     Endpoint,
     Engagement,
@@ -20,240 +41,167 @@
     Languages,
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
     Product_Type,
-    Product_Type_Group,
-    Product_Type_Member,
     Risk_Acceptance,
     Test,
 )
-from dojo.request_cache import cache_for_request
 
 
 def user_has_configuration_permission(user: Dojo_User, permission: str):
+    """
+    Legacy: configuration permissions reduce to is_superuser / is_staff,
+    matching the rest of the legacy auth model. ``user.has_perm`` is
+    still consulted as a fallback so explicit Django permission grants
+    (e.g. ``auth.add_user`` granted via Django Admin) keep working for
+    non-staff users. Pro overrides this function at runtime via
+    pro/apps.py:_shadow_authorization_symbols, so this OS bypass does
+    not affect Pro deployments.
+    """
     if not user:
         return False
-
     if user.is_anonymous:
         return False
-
+    if user.is_superuser or user.is_staff:
+        return True
     return user.has_perm(permission)
 
 
 def user_is_superuser_or_global_owner(user: Dojo_User) -> bool:
     """
-    Returns True if the user is a superuser or has a global role (directly or
-    via group membership) whose Role.is_owner is True.
+    Legacy: there is no Owner role; only the superuser flag elevates
+    a user to system-wide authority.
     """
     if not user or getattr(user, "is_anonymous", False):
         return False
+    return bool(user.is_superuser)
 
+
+def user_has_permission(user: Dojo_User, obj: Model, permission) -> bool:
+    """
+    Legacy object-level authorization check.
+
+    Resolution order:
+
+      1. anonymous → deny
+      2. superuser → allow
+      3. action → mapped from Permissions / string / Action via permission_to_action
+      4. SuperuserOnly action → deny (already handled superuser above)
+      5. StaffOnly / Delete → require is_staff
+      6. View / Edit / Add / Import → is_staff bypasses unconditionally,
+         otherwise check membership in the obj.authorized_users chain
+         (climbing Product_Type ← Product ← Engagement ← Test ← Finding).
+         This matches the pre-Auth-V2 (pre-2020) behavior where is_staff
+         was an absolute bypass on every perm_type — see
+         dojo/user/helper.py at commit e7805aa14~ for the historical
+         reference.
+
+    Carrier objects don't expose authorized_users themselves; they
+    delegate to their wrapped product or product type.
+    """
+    if not user or getattr(user, "is_anonymous", False):
+        return False
     if user.is_superuser:
         return True
 
-    if (
-        hasattr(user, "global_role")
-        and user.global_role.role is not None
-        and user.global_role.role.is_owner
-    ):
-        return True
+    action = permission_to_action(permission)
 
-    for group in get_groups(user):
-        if (
-            hasattr(group, "global_role")
-            and group.global_role.role is not None
-            and group.global_role.role.is_owner
-        ):
-            return True
+    if action == Action.SuperuserOnly:
+        return False
 
-    return False
+    if action in {Action.StaffOnly, Action.Delete}:
+        return bool(user.is_staff)
 
+    return _user_authorized_for(user, obj, action)
 
-def user_has_permission(user: Dojo_User, obj: Model, permission: int) -> bool:
-    if user.is_anonymous:
-        return False
 
-    if user.is_superuser:
-        return True
+def _user_authorized_for(user: Dojo_User, obj: Model, action: Action) -> bool:
+    """
+    Membership-chain check. Returns True if user has any membership that
+    grants ``action`` on ``obj``.
+    """
+    if obj is None:
+        return False
 
-    if isinstance(obj, Product_Type | Product):
-        # Global roles are only relevant for product types, products and their
-        # dependent objects
-        if user_has_global_permission(user, permission):
+    if isinstance(obj, Product_Type):
+        if user.is_staff:
             return True
+        return obj.pk in authorized_product_type_id_set(user.pk)
 
-    if isinstance(obj, Product_Type):
-        # Check if the user has a role for the product type with the requested
-        # permissions
-        member = get_product_type_member(user, obj)
-        if member is not None and role_has_permission(
-            member.role.id, permission,
-        ):
+    if isinstance(obj, Product):
+        if user.is_staff:
             return True
-        # Check if the user is in a group with a role for the product type with
-        # the requested permissions
-        for product_type_group in get_product_type_groups(user, obj):
-            if role_has_permission(product_type_group.role.id, permission):
-                return True
+        # authorized_product_id_set folds direct Product membership AND
+        # inherited membership via prod_type into one cached lookup.
+        return obj.pk in authorized_product_id_set(user.pk)
+
+    if isinstance(obj, Engagement):
+        return _user_authorized_for(user, obj.product, action)
+
+    if isinstance(obj, Test):
+        return _user_authorized_for(user, obj.engagement.product, action) if obj.engagement_id else False
+
+    if isinstance(obj, Finding):
+        return _user_authorized_for(user, obj.test.engagement.product, action)
+
+    if isinstance(obj, Finding_Group):
+        return _user_authorized_for(user, obj.test.engagement.product, action)
+
+    if isinstance(obj, Risk_Acceptance):
+        # Risk_Acceptance is reachable from Engagement via the reverse M2M
+        # `engagement.risk_acceptance`. Pre-2020 followed the same path
+        # (see dojo/user/helper.py at e7805aa14~).
+        engagement = obj.engagement_set.first()
+        if engagement is not None:
+            return _user_authorized_for(user, engagement.product, action)
         return False
-    if (
-        isinstance(obj, Product)
-        and permission.value >= Permissions.Product_View.value
-    ):
-        # Products inherit permissions of their product type
-        if user_has_permission(user, obj.prod_type, permission):
-            return True
 
-        # Check if the user has a role for the product with the requested
-        # permissions
-        member = get_product_member(user, obj)
-        if member is not None and role_has_permission(
-            member.role.id, permission,
-        ):
+    if isinstance(obj, Location):
+        return any(_user_authorized_for(user, ref.product, action) for ref in obj.products.all())
+
+    if isinstance(obj, AbstractLocation):
+        return _user_authorized_for(user, obj.location, action)
+
+    if isinstance(obj, Endpoint | Languages | App_Analysis | Product_API_Scan_Configuration):
+        return _user_authorized_for(user, obj.product, action)
+
+    if isinstance(obj, Dojo_Group):
+        # Groups and the role tables live in RBAC territory; under the legacy
+        # OS model they are staff-only. Pro's runtime shadow re-routes these
+        # calls to the RBAC implementation for Pro deployments.
+        return bool(user.is_staff)
+
+    if isinstance(obj, Dojo_Group_Member | Product_Member | Product_Type_Member):
+        # Membership rows: a user can always act on a membership row that
+        # references themselves (self-removal); otherwise it's staff-only.
+        if obj.user_id == user.pk:
             return True
-        # Check if the user is in a group with a role for the product with the
-        # requested permissions
-        for product_group in get_product_groups(user, obj):
-            if role_has_permission(product_group.role.id, permission):
-                return True
-        return False
-    if (
-        isinstance(obj, Engagement)
-        and permission in Permissions.get_engagement_permissions()
-    ):
-        return user_has_permission(user, obj.product, permission)
-    if (
-        isinstance(obj, Test)
-        and permission in Permissions.get_test_permissions()
-    ) or (
-        isinstance(obj, Risk_Acceptance)
-        and permission == Permissions.Risk_Acceptance
-    ):
-        if obj.engagement is not None:
-            return user_has_permission(user, obj.engagement.product, permission)
-        return user_has_global_permission(user, permission)
-    if (
-        isinstance(obj, Finding) and permission in Permissions.get_finding_permissions()
-    ) or (
-        isinstance(obj, Finding_Group)
-        and permission in Permissions.get_finding_group_permissions()
-    ):
-        return user_has_permission(
-            user, obj.test.engagement.product, permission,
-        )
-    if (
-        isinstance(obj, Location)
-        and permission in Permissions.get_location_permissions()
-    ):
-        return any(user_has_permission(user, ref.product, permission) for ref in obj.products.all())
-    if (
-        isinstance(obj, AbstractLocation)
-        and permission in Permissions.get_location_permissions()
-    ):
-        return user_has_permission(user, obj.location, permission)
-    if (
-        # TODO: Delete this after the move to Locations
-        isinstance(obj, Endpoint)
-        and permission in Permissions.get_location_permissions()
-    ) or (
-        isinstance(obj, Languages)
-        and permission in Permissions.get_language_permissions()
-    ) or (
-        isinstance(obj, App_Analysis)
-        and permission in Permissions.get_technology_permissions()
-    ) or (
-        isinstance(obj, Product_API_Scan_Configuration)
-        and permission
-        in Permissions.get_product_api_scan_configuration_permissions()
-    ):
-        return user_has_permission(user, obj.product, permission)
-    if (
-        isinstance(obj, Product_Type_Member)
-        and permission in Permissions.get_product_type_member_permissions()
-    ):
-        if permission == Permissions.Product_Type_Member_Delete:
-            # Every member is allowed to remove himself
-            return obj.user == user or user_has_permission(
-                user, obj.product_type, permission,
-            )
-        return user_has_permission(user, obj.product_type, permission)
-    if (
-        isinstance(obj, Product_Member)
-        and permission in Permissions.get_product_member_permissions()
-    ):
-        if permission == Permissions.Product_Member_Delete:
-            # Every member is allowed to remove himself
-            return obj.user == user or user_has_permission(
-                user, obj.product, permission,
-            )
-        return user_has_permission(user, obj.product, permission)
-    if (
-        isinstance(obj, Product_Type_Group)
-        and permission in Permissions.get_product_type_group_permissions()
-    ):
-        return user_has_permission(user, obj.product_type, permission)
-    if (
-        isinstance(obj, Product_Group)
-        and permission in Permissions.get_product_group_permissions()
-    ):
-        return user_has_permission(user, obj.product, permission)
-    if (
-        isinstance(obj, Dojo_Group)
-        and permission in Permissions.get_group_permissions()
-    ):
-        # Check if the user has a role for the group with the requested
-        # permissions
-        group_member = get_group_member(user, obj)
-        return group_member is not None and role_has_permission(
-            group_member.role.id, permission,
-        )
-    if (
-        isinstance(obj, Dojo_Group_Member)
-        and permission in Permissions.get_group_member_permissions()
-    ):
-        if permission == Permissions.Group_Member_Delete:
-            # Every user is allowed to remove himself
-            return obj.user == user or user_has_permission(
-                user, obj.group, permission,
-            )
-        return user_has_permission(user, obj.group, permission)
-    msg = f"No authorization implemented for class {type(obj).__name__} and permission {permission}"
+        return bool(user.is_staff)
+
+    msg = f"No legacy authorization implemented for class {type(obj).__name__}"
     raise NoAuthorizationImplementedError(msg)
 
 
-def user_has_global_permission(user: Dojo_User, permission: int) -> bool:
-    if not user:
-        return False
+def user_has_global_permission(user: Dojo_User, permission) -> bool:
+    """
+    Legacy: global permissions reduce to is_superuser / is_staff.
 
-    if user.is_anonymous:
+    The one Django configuration-permission carve-out preserved from the
+    pre-2020 model: ``dojo.add_product_type`` lets a non-staff user
+    create product types if explicitly granted via Django auth.
+    """
+    if not user or getattr(user, "is_anonymous", False):
         return False
-
     if user.is_superuser:
         return True
 
-    if permission == Permissions.Product_Type_Add:
-        if user_has_configuration_permission(user, "dojo.add_product_type"):
-            return True
+    action = permission_to_action(permission)
 
-    if (
-        hasattr(user, "global_role")
-        and user.global_role.role is not None
-        and role_has_global_permission(user.global_role.role.id, permission)
-    ):
+    if permission == "add" and user_has_configuration_permission(user, "dojo.add_product_type"):
         return True
 
-    for group in get_groups(user):
-        if (
-            hasattr(group, "global_role")
-            and group.global_role.role is not None
-            and role_has_global_permission(
-                group.global_role.role.id, permission,
-            )
-        ):
-            return True
-
-    return False
+    if action == Action.SuperuserOnly:
+        return False
+    return bool(user.is_staff)
 
 
 def user_has_configuration_permission_or_403(user: Dojo_User, permission: str) -> None:
@@ -261,53 +209,41 @@ def user_has_configuration_permission_or_403(user: Dojo_User, permission: str) -
         raise PermissionDenied
 
 
-def user_has_permission_or_403(user: Dojo_User, obj: Model, permission: int) -> None:
+def user_has_permission_or_403(user: Dojo_User, obj: Model, permission) -> None:
     if not user_has_permission(user, obj, permission):
         raise PermissionDenied
 
 
-def user_has_global_permission_or_403(user: Dojo_User, permission: int) -> None:
+def user_has_global_permission_or_403(user: Dojo_User, permission) -> None:
     if not user_has_global_permission(user, permission):
         raise PermissionDenied
 
 
-def get_roles_for_permission(permission: int) -> set[int]:
-    if not Permissions.has_value(permission):
-        msg = f"Permission {permission} does not exist"
-        raise PermissionDoesNotExistError(msg)
-    roles_for_permissions = set()
-    roles = get_roles_with_permissions()
-    for role in roles:
-        permissions = roles.get(role)
-        if permission in permissions:
-            roles_for_permissions.add(role)
-    return roles_for_permissions
+# ---------------------------------------------------------------------------
+# Inert role-based helpers.
+#
+# The hierarchical RBAC roles (Reader / Writer / Maintainer / Owner /
+# API_Importer) live in dojo-pro now; OS-only deployments authorize via
+# is_superuser / is_staff / authorized_users only. These three helpers are
+# kept as stubs so transitional callers that haven't dropped the role
+# lookups don't AttributeError. They always return the "no role grants
+# this permission" answer; OS deployments don't consult roles anyway.
+# ---------------------------------------------------------------------------
 
 
-def role_has_permission(role: int, permission: int) -> bool:
-    if role is None:
-        return False
-    if not Roles.has_value(role):
-        msg = f"Role {role} does not exist"
-        raise RoleDoesNotExistError(msg)
-    roles = get_roles_with_permissions()
-    permissions = roles.get(role)
-    if not permissions:
-        return False
-    return permission in permissions
+def get_roles_for_permission(permission) -> set:
+    """Inert stub. Legacy OS auth does not consult roles."""
+    return set()
 
 
-def role_has_global_permission(role: int, permission: int) -> bool:
-    if role is None:
-        return False
-    if not Roles.has_value(role):
-        msg = f"Role {role} does not exist"
-        raise RoleDoesNotExistError(msg)
-    roles = get_global_roles_with_permissions()
-    permissions = roles.get(role)
-    if permissions and permission in permissions:
-        return True
-    return role_has_permission(role, permission)
+def role_has_permission(role, permission) -> bool:
+    """Inert stub. Legacy OS auth does not consult roles."""
+    return False
+
+
+def role_has_global_permission(role, permission) -> bool:
+    """Inert stub. Legacy OS auth does not consult roles."""
+    return False
 
 
 class NoAuthorizationImplementedError(Exception):
@@ -323,95 +259,3 @@ def __init__(self, message):
 class RoleDoesNotExistError(Exception):
     def __init__(self, message):
         self.message = message
-
-
-def get_product_member(user: Dojo_User, product: Product) -> Product_Member | None:
-    return get_product_member_dict(user).get(product.id)
-
-
-@cache_for_request
-def get_product_member_dict(user: Dojo_User) -> dict[int, Product_Member]:
-    pm_dict = {}
-    for product_member in (
-        Product_Member.objects.select_related("product")
-        .select_related("role")
-        .filter(user=user)
-    ):
-        pm_dict[product_member.product.id] = product_member
-    return pm_dict
-
-
-def get_product_type_member(user: Dojo_User, product_type: Product_Type) -> Product_Type_Member | None:
-    return get_product_type_member_dict(user).get(product_type.id)
-
-
-@cache_for_request
-def get_product_type_member_dict(user: Dojo_User) -> dict[int, Product_Type_Member]:
-    ptm_dict = {}
-    for product_type_member in (
-        Product_Type_Member.objects.select_related("product_type")
-        .select_related("role")
-        .filter(user=user)
-    ):
-        ptm_dict[product_type_member.product_type.id] = product_type_member
-    return ptm_dict
-
-
-def get_product_groups(user: Dojo_User, product: Product) -> list[Product_Group]:
-    return get_product_groups_dict(user).get(product.id, [])
-
-
-@cache_for_request
-def get_product_groups_dict(user: Dojo_User) -> dict[int, list[Product_Group]]:
-    pg_dict = {}
-    for product_group in (
-        Product_Group.objects.select_related("product")
-        .select_related("role")
-        .filter(group__users=user)
-    ):
-        pgu_list = [] if pg_dict.get(product_group.product.id) is None else pg_dict[product_group.product.id]
-        pgu_list.append(product_group)
-        pg_dict[product_group.product.id] = pgu_list
-    return pg_dict
-
-
-def get_product_type_groups(user: Dojo_User, product_type: Product_Type) -> list[Product_Type_Group]:
-    return get_product_type_groups_dict(user).get(product_type.id, [])
-
-
-@cache_for_request
-def get_product_type_groups_dict(user: Dojo_User) -> dict[int, list[Product_Type_Group]]:
-    pgt_dict = {}
-    for product_type_group in (
-        Product_Type_Group.objects.select_related("product_type")
-        .select_related("role")
-        .filter(group__users=user)
-    ):
-        if pgt_dict.get(product_type_group.product_type.id) is None:
-            pgtu_list = []
-        else:
-            pgtu_list = pgt_dict[product_type_group.product_type.id]
-        pgtu_list.append(product_type_group)
-        pgt_dict[product_type_group.product_type.id] = pgtu_list
-    return pgt_dict
-
-
-@cache_for_request
-def get_groups(user: Dojo_User) -> QuerySet[Dojo_Group]:
-    return Dojo_Group.objects.select_related("global_role").filter(users=user)
-
-
-def get_group_member(user: Dojo_User, group: Dojo_Group) -> dict[int, Dojo_Group_Member]:
-    return get_group_members_dict(user).get(group.id)
-
-
-@cache_for_request
-def get_group_members_dict(user: Dojo_User) -> dict[int, Dojo_Group_Member]:
-    gu_dict = {}
-    for group_member in (
-        Dojo_Group_Member.objects.select_related("group")
-        .select_related("role")
-        .filter(user=user)
-    ):
-        gu_dict[group_member.group.id] = group_member
-    return gu_dict
diff --git a/dojo/authorization/middleware.py b/dojo/authorization/middleware.py
new file mode 100644
index 00000000000..f5543753a0c
--- /dev/null
+++ b/dojo/authorization/middleware.py
@@ -0,0 +1,50 @@
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_object_or_404
+
+from dojo.authorization.authorization import (
+    user_has_configuration_permission,
+    user_has_global_permission_or_403,
+    user_has_permission_or_403,
+)
+from dojo.authorization.url_permissions import URL_PERMISSIONS
+
+
+class AuthorizationMiddleware:
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        return self.get_response(request)
+
+    def process_view(self, request, view_func, view_args, view_kwargs):
+        # Skip API paths -- DRF has its own permission classes
+        if request.path.startswith("/api/"):
+            return
+
+        resolver_match = request.resolver_match
+        if resolver_match is None:
+            return
+
+        url_name = resolver_match.url_name
+        checks = URL_PERMISSIONS.get(url_name)
+        if not checks:
+            return
+
+        for check in checks:
+            check_type = check[0]
+            if check_type == "global":
+                _, permission = check
+                user_has_global_permission_or_403(request.user, permission)
+            elif check_type == "config":
+                _, permission = check
+                if not user_has_configuration_permission(request.user, permission):
+                    raise PermissionDenied
+            elif check_type == "object":
+                _, model, permission, arg_name = check
+                lookup_value = view_kwargs.get(arg_name)
+                if lookup_value is None:
+                    continue  # kwarg not present, skip this check
+                obj = get_object_or_404(model, pk=lookup_value)
+                user_has_permission_or_403(request.user, obj, permission)
+
+        return
diff --git a/dojo/authorization/models.py b/dojo/authorization/models.py
new file mode 100644
index 00000000000..bae1b078eea
--- /dev/null
+++ b/dojo/authorization/models.py
@@ -0,0 +1,161 @@
+"""
+Legacy backward-compat shells for the seven RBAC model classes.
+
+The canonical owner of these tables is now ``pro.authorization.models``.
+After the paired ``SeparateDatabaseAndState`` migrations land
+(``dojo.0268_release_rbac_state`` + ``pro.000X_adopt_rbac_tables``),
+Pro's state owns the seven RBAC tables and OS's app state no longer
+references them.
+
+The class definitions remain here as ``managed=False`` shells purely so
+the existing OS code that imports / isinstance-checks ``dojo.authorization
+.models.X`` keeps compiling. Track B step #13 simplifies the callers and
+deletes these shells entirely; until that lands, the shells let OS-only
+deployments stay functional with the legacy authorization model.
+"""
+
+from django.contrib.auth.models import Group
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class Dojo_Group(models.Model):
+
+    """
+    ``managed=False`` shell for the canonical ``Dojo_Group`` model, owned by
+    ``pro.groups.models``. Mirrors the seven RBAC shells below: the state
+    entry stays so historical pro migrations whose state references
+    ``dojo.dojo_group`` (e.g. ``pro.0001`` ``EnhancedDojoGroup.group`` and
+    ``pro.0034`` proxy ``bases=("dojo.dojo_group",)``) keep resolving when
+    Django reloads project state. Pro's ``CreateModel(Dojo_Group)`` in
+    ``pro.0053_adopt_dojo_group`` is the ``managed=True`` canonical owner;
+    both states share ``db_table="dojo_dojo_group"`` so no DDL conflicts.
+    Reverse accessors are suppressed with ``related_name="+"`` so they
+    don't clash with ``pro.Dojo_Group``'s own accessors.
+    """
+
+    AZURE = "AzureAD"
+    REMOTE = "Remote"
+    SOCIAL_CHOICES = (
+        (AZURE, _("AzureAD")),
+        (REMOTE, _("Remote")),
+    )
+    name = models.CharField(max_length=255, unique=True)
+    description = models.CharField(max_length=4000, null=True, blank=True)
+    users = models.ManyToManyField(
+        "dojo.Dojo_User",
+        through="dojo.Dojo_Group_Member",
+        related_name="+",
+        blank=True,
+    )
+    auth_group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.CASCADE, related_name="+")
+    social_provider = models.CharField(
+        max_length=10,
+        choices=SOCIAL_CHOICES,
+        blank=True,
+        null=True,
+        help_text=_("Group imported from a social provider."),
+        verbose_name=_("Social Authentication Provider"),
+    )
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_dojo_group"
+        managed = False
+
+    def __str__(self):
+        return self.name
+
+
+class Role(models.Model):
+    name = models.CharField(max_length=255, unique=True)
+    is_owner = models.BooleanField(default=False)
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_role"
+        managed = False
+        ordering = ("name",)
+
+    def __str__(self):
+        return self.name
+
+
+class Dojo_Group_Member(models.Model):
+    group = models.ForeignKey("dojo.Dojo_Group", on_delete=models.CASCADE, related_name="+")
+    user = models.ForeignKey("dojo.Dojo_User", on_delete=models.CASCADE, related_name="+")
+    role = models.ForeignKey(
+        Role,
+        on_delete=models.CASCADE,
+        related_name="+",
+        help_text=_("This role determines the permissions of the user to manage the group."),
+        verbose_name=_("Group role"),
+    )
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_dojo_group_member"
+        managed = False
+
+
+class Global_Role(models.Model):
+    user = models.OneToOneField("dojo.Dojo_User", null=True, blank=True, on_delete=models.CASCADE, related_name="+")
+    group = models.OneToOneField("dojo.Dojo_Group", null=True, blank=True, on_delete=models.CASCADE, related_name="+")
+    role = models.ForeignKey(
+        Role,
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+        related_name="+",
+        help_text=_("The global role will be applied to all product types and products."),
+        verbose_name=_("Global role"),
+    )
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_global_role"
+        managed = False
+
+
+class Product_Member(models.Model):
+    product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE, related_name="+")
+    user = models.ForeignKey("dojo.Dojo_User", on_delete=models.CASCADE, related_name="+")
+    role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="+")
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_product_member"
+        managed = False
+
+
+class Product_Group(models.Model):
+    product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE, related_name="+")
+    group = models.ForeignKey("dojo.Dojo_Group", on_delete=models.CASCADE, related_name="+")
+    role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="+")
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_product_group"
+        managed = False
+
+
+class Product_Type_Member(models.Model):
+    product_type = models.ForeignKey("dojo.Product_Type", on_delete=models.CASCADE, related_name="+")
+    user = models.ForeignKey("dojo.Dojo_User", on_delete=models.CASCADE, related_name="+")
+    role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="+")
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_product_type_member"
+        managed = False
+
+
+class Product_Type_Group(models.Model):
+    product_type = models.ForeignKey("dojo.Product_Type", on_delete=models.CASCADE, related_name="+")
+    group = models.ForeignKey("dojo.Dojo_Group", on_delete=models.CASCADE, related_name="+")
+    role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="+")
+
+    class Meta:
+        app_label = "dojo"
+        db_table = "dojo_product_type_group"
+        managed = False
diff --git a/dojo/authorization/query_filters.py b/dojo/authorization/query_filters.py
new file mode 100644
index 00000000000..d987c2ce068
--- /dev/null
+++ b/dojo/authorization/query_filters.py
@@ -0,0 +1,15 @@
+_AUTH_FILTER_REGISTRY = {}
+
+
+def register_auth_filter(key, func, *, override=False):
+    # Defaults register without override and never clobber an existing entry.
+    # Plugins replacing a default (e.g. Pro's RBAC implementations) must pass
+    # override=True. This makes the wiring order-independent: regardless of
+    # which AppConfig.ready() runs first, the explicit-override side wins.
+    if key in _AUTH_FILTER_REGISTRY and not override:
+        return
+    _AUTH_FILTER_REGISTRY[key] = func
+
+
+def get_auth_filter(key):
+    return _AUTH_FILTER_REGISTRY.get(key)
diff --git a/dojo/authorization/query_registrations.py b/dojo/authorization/query_registrations.py
new file mode 100644
index 00000000000..e5a2941b487
--- /dev/null
+++ b/dojo/authorization/query_registrations.py
@@ -0,0 +1,447 @@
+"""
+OS authorization queryset filters.
+
+Each filter restricts results to objects whose underlying Product /
+Product_Type the user is a member of (via ``authorized_users``), with
+``is_superuser`` and ``is_staff`` bypasses. RBAC carrier queries (Member,
+Group, Global_Role) are not registered here — Pro registers its own
+implementations at startup that consult those tables.
+
+The dojo/authorization/queries entry-point names (e.g. ``product.get_
+authorized_products``) are preserved so the per-app queries.py modules and
+the API filter classes that look them up via ``get_auth_filter()`` keep
+working without code changes.
+"""
+from crum import get_current_user
+from django.db.models import Q
+
+from dojo.authorization.query_filters import register_auth_filter
+from dojo.authorization.roles_permissions import permission_to_action
+from dojo.location.models import Location, LocationFindingReference, LocationProductReference
+from dojo.models import (
+    App_Analysis,
+    Dojo_User,
+    DojoMeta,
+    Endpoint,
+    Endpoint_Status,
+    Engagement,
+    Engagement_Presets,
+    Finding,
+    Finding_Group,
+    JIRA_Issue,
+    JIRA_Project,
+    Languages,
+    Product,
+    Product_API_Scan_Configuration,
+    Product_Type,
+    Risk_Acceptance,
+    Test,
+    Test_Import,
+    Tool_Product_Settings,
+    Vulnerability_Id,
+)
+from dojo.request_cache import cache_for_request
+
+
+def _resolve_user(user):
+    return user if user is not None else get_current_user()
+
+
+def _is_unrestricted(user, action):
+    """
+    Returns True if the user can see every object regardless of membership.
+    Superuser and staff both bypass — matches pre-2020 behavior where
+    is_staff was an absolute bypass for every perm_type. The ``action``
+    arg is retained for callers that may want to gate StaffOnly /
+    SuperuserOnly differently in the future.
+    """
+    if not user or getattr(user, "is_anonymous", False):
+        return False
+    if user.is_superuser:
+        return True
+    return bool(user.is_staff)
+
+
+def _authorized_product_ids(user):
+    """
+    QuerySet of product ids the user can access via authorized_users.
+    Lazy on purpose — callers that pass this into ``.filter(id__in=...)``
+    let Postgres collapse it into a single subquery.
+    """
+    return Product.objects.filter(
+        Q(authorized_users=user) | Q(prod_type__authorized_users=user),
+    ).values("id")
+
+
+def _authorized_product_type_ids(user):
+    """
+    QuerySet of product_type ids the user can access via authorized_users.
+    Lazy on purpose (see ``_authorized_product_ids``).
+    """
+    return Product_Type.objects.filter(authorized_users=user).values("id")
+
+
+@cache_for_request
+def authorized_product_id_set(user_pk):
+    """
+    Frozen set of product ids the user can access via authorized_users
+    (direct or via prod_type). Result is cached for the lifetime of the
+    current request — repeated per-object permission checks (one per
+    object, often dozens per request) collapse to a single SELECT.
+
+    Returns an empty frozenset for anonymous / missing users so callers
+    can do ``pid in set`` without a None check.
+    """
+    if not user_pk:
+        return frozenset()
+    return frozenset(
+        Product.objects.filter(
+            Q(authorized_users=user_pk) | Q(prod_type__authorized_users=user_pk),
+        ).values_list("id", flat=True),
+    )
+
+
+@cache_for_request
+def authorized_product_type_id_set(user_pk):
+    """
+    Frozen set of product_type ids the user is a direct member of via
+    authorized_users. Cached per request (see ``authorized_product_id_set``).
+    """
+    if not user_pk:
+        return frozenset()
+    return frozenset(
+        Product_Type.objects.filter(authorized_users=user_pk).values_list("id", flat=True),
+    )
+
+
+def _filter_by_authorized_products(queryset, product_path, permission, user=None):
+    """
+    Generic helper: restrict ``queryset`` to rows whose ``product_path`` FK
+    points at a Product the user is authorized for. ``product_path`` is a
+    Django ORM lookup like ``"product"`` or ``"engagement__product"``.
+    """
+    user = _resolve_user(user)
+    if user is None or getattr(user, "is_anonymous", False):
+        return queryset.none()
+    action = permission_to_action(permission)
+    if _is_unrestricted(user, action):
+        return queryset
+    return queryset.filter(**{f"{product_path}__id__in": _authorized_product_ids(user)})
+
+
+# ---------------------------------------------------------------------------
+# Product / Product_Type
+# ---------------------------------------------------------------------------
+
+
+def _get_authorized_products(permission, user=None):
+    user = _resolve_user(user)
+    if user is None or getattr(user, "is_anonymous", False):
+        return Product.objects.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return Product.objects.all().order_by("name")
+    return Product.objects.filter(
+        Q(authorized_users=user) | Q(prod_type__authorized_users=user),
+    ).distinct().order_by("name")
+
+
+register_auth_filter("product.get_authorized_products", _get_authorized_products)
+
+
+def _get_authorized_product_types(permission, user=None):
+    user = _resolve_user(user)
+    if user is None or getattr(user, "is_anonymous", False):
+        return Product_Type.objects.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return Product_Type.objects.all().order_by("name")
+    return Product_Type.objects.filter(authorized_users=user).order_by("name")
+
+
+register_auth_filter("product_type.get_authorized_product_types", _get_authorized_product_types)
+
+
+# ---------------------------------------------------------------------------
+# Children of Product / Product_Type (membership inherited)
+# ---------------------------------------------------------------------------
+
+
+def _get_authorized_engagements(permission):
+    return _filter_by_authorized_products(Engagement.objects.all(), "product", permission)
+
+
+register_auth_filter("engagement.get_authorized_engagements", _get_authorized_engagements)
+
+
+def _get_authorized_tests(permission, product=None):
+    qs = Test.objects.all()
+    if product is not None:
+        qs = qs.filter(engagement__product=product)
+    return _filter_by_authorized_products(qs, "engagement__product", permission)
+
+
+register_auth_filter("test.get_authorized_tests", _get_authorized_tests)
+
+
+def _get_authorized_test_imports(permission):
+    return _filter_by_authorized_products(Test_Import.objects.all(), "test__engagement__product", permission)
+
+
+register_auth_filter("test.get_authorized_test_imports", _get_authorized_test_imports)
+
+
+def _get_authorized_risk_acceptances(permission):
+    return _filter_by_authorized_products(Risk_Acceptance.objects.all(), "engagement__product", permission)
+
+
+register_auth_filter("risk_acceptance.get_authorized_risk_acceptances", _get_authorized_risk_acceptances)
+
+
+def _get_authorized_finding_groups(permission, user=None):
+    return _filter_by_authorized_products(
+        Finding_Group.objects.all(), "test__engagement__product", permission, user=user,
+    )
+
+
+register_auth_filter("finding_group.get_authorized_finding_groups", _get_authorized_finding_groups)
+
+
+def _get_authorized_finding_groups_for_queryset(permission, queryset, user=None):
+    return _filter_by_authorized_products(queryset, "test__engagement__product", permission, user=user)
+
+
+register_auth_filter("finding_group.get_authorized_finding_groups_for_queryset", _get_authorized_finding_groups_for_queryset)
+
+
+def _get_authorized_app_analysis(permission):
+    return _filter_by_authorized_products(App_Analysis.objects.all(), "product", permission)
+
+
+register_auth_filter("product.get_authorized_app_analysis", _get_authorized_app_analysis)
+
+
+def _get_authorized_dojo_meta(permission):
+    user = get_current_user()
+    if user is None or getattr(user, "is_anonymous", False):
+        return DojoMeta.objects.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return DojoMeta.objects.all()
+    authorized_products = _authorized_product_ids(user)
+    authorized_product_types = _authorized_product_type_ids(user)
+    return DojoMeta.objects.filter(
+        Q(product__id__in=authorized_products)
+        | Q(product_type__id__in=authorized_product_types)
+        | Q(finding__test__engagement__product__id__in=authorized_products)
+        | Q(endpoint__product__id__in=authorized_products),
+    )
+
+
+register_auth_filter("product.get_authorized_dojo_meta", _get_authorized_dojo_meta)
+
+
+def _get_authorized_languages(permission):
+    return _filter_by_authorized_products(Languages.objects.all(), "product", permission)
+
+
+register_auth_filter("product.get_authorized_languages", _get_authorized_languages)
+
+
+def _get_authorized_engagement_presets(permission):
+    return _filter_by_authorized_products(Engagement_Presets.objects.all(), "product", permission)
+
+
+register_auth_filter("product.get_authorized_engagement_presets", _get_authorized_engagement_presets)
+
+
+def _get_authorized_product_api_scan_configurations(permission):
+    return _filter_by_authorized_products(
+        Product_API_Scan_Configuration.objects.all(), "product", permission,
+    )
+
+
+register_auth_filter("product.get_authorized_product_api_scan_configurations", _get_authorized_product_api_scan_configurations)
+
+
+def _get_authorized_jira_projects(permission, user=None):
+    user = _resolve_user(user)
+    if user is None or getattr(user, "is_anonymous", False):
+        return JIRA_Project.objects.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return JIRA_Project.objects.all()
+    authorized_products = _authorized_product_ids(user)
+    authorized_product_types = _authorized_product_type_ids(user)
+    return JIRA_Project.objects.filter(
+        Q(product__id__in=authorized_products)
+        | Q(product__prod_type__id__in=authorized_product_types)
+        | Q(engagement__product__id__in=authorized_products),
+    ).distinct()
+
+
+register_auth_filter("jira_link.get_authorized_jira_projects", _get_authorized_jira_projects)
+
+
+def _get_authorized_jira_issues(permission):
+    user = get_current_user()
+    if user is None or getattr(user, "is_anonymous", False):
+        return JIRA_Issue.objects.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return JIRA_Issue.objects.all()
+    authorized_products = _authorized_product_ids(user)
+    return JIRA_Issue.objects.filter(
+        Q(engagement__product__id__in=authorized_products)
+        | Q(finding__test__engagement__product__id__in=authorized_products)
+        | Q(finding_group__test__engagement__product__id__in=authorized_products),
+    )
+
+
+register_auth_filter("jira_link.get_authorized_jira_issues", _get_authorized_jira_issues)
+
+
+def _get_authorized_tool_product_settings(permission):
+    return _filter_by_authorized_products(Tool_Product_Settings.objects.all(), "product", permission)
+
+
+register_auth_filter("tool_product.get_authorized_tool_product_settings", _get_authorized_tool_product_settings)
+
+
+# ---------------------------------------------------------------------------
+# Locations
+# ---------------------------------------------------------------------------
+
+
+def _get_authorized_locations(permission, queryset=None, user=None):
+    user = _resolve_user(user)
+    qs = queryset if queryset is not None else Location.objects.all()
+    if user is None or getattr(user, "is_anonymous", False):
+        return qs.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return qs
+    authorized_products = _authorized_product_ids(user)
+    return qs.filter(products__product__id__in=authorized_products).distinct()
+
+
+register_auth_filter("location.get_authorized_locations", _get_authorized_locations)
+
+
+def _get_authorized_location_finding_reference(permission, queryset=None, user=None):
+    user = _resolve_user(user)
+    qs = queryset if queryset is not None else LocationFindingReference.objects.all()
+    if user is None or getattr(user, "is_anonymous", False):
+        return qs.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return qs
+    authorized_products = _authorized_product_ids(user)
+    return qs.filter(finding__test__engagement__product__id__in=authorized_products)
+
+
+register_auth_filter("location.get_authorized_location_finding_reference", _get_authorized_location_finding_reference)
+
+
+def _get_authorized_location_product_reference(permission, queryset=None, user=None):
+    user = _resolve_user(user)
+    qs = queryset if queryset is not None else LocationProductReference.objects.all()
+    if user is None or getattr(user, "is_anonymous", False):
+        return qs.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return qs
+    authorized_products = _authorized_product_ids(user)
+    return qs.filter(product__id__in=authorized_products)
+
+
+register_auth_filter("location.get_authorized_location_product_reference", _get_authorized_location_product_reference)
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+def _get_authorized_endpoints(permission, user=None):
+    return _filter_by_authorized_products(Endpoint.objects.all(), "product", permission, user=user)
+
+
+register_auth_filter("endpoint.get_authorized_endpoints", _get_authorized_endpoints)
+
+
+def _get_authorized_endpoint_status(permission, user=None):
+    return _filter_by_authorized_products(
+        Endpoint_Status.objects.all(), "endpoint__product", permission, user=user,
+    )
+
+
+register_auth_filter("endpoint.get_authorized_endpoint_status", _get_authorized_endpoint_status)
+
+
+# ---------------------------------------------------------------------------
+# Findings / Vulnerability_Ids
+# ---------------------------------------------------------------------------
+
+
+def _get_authorized_findings(permission, queryset=None, user=None):
+    user = _resolve_user(user)
+    qs = queryset if queryset is not None else Finding.objects.all()
+    if user is None or getattr(user, "is_anonymous", False):
+        return qs.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return qs
+    return qs.filter(test__engagement__product__id__in=_authorized_product_ids(user))
+
+
+register_auth_filter("finding.get_authorized_findings", _get_authorized_findings)
+
+
+def _get_authorized_vulnerability_ids(permission, queryset=None, user=None):
+    user = _resolve_user(user)
+    qs = queryset if queryset is not None else Vulnerability_Id.objects.all()
+    if user is None or getattr(user, "is_anonymous", False):
+        return qs.none()
+    if _is_unrestricted(user, permission_to_action(permission)):
+        return qs
+    return qs.filter(finding__test__engagement__product__id__in=_authorized_product_ids(user))
+
+
+register_auth_filter("finding.get_authorized_vulnerability_ids", _get_authorized_vulnerability_ids)
+
+
+# ---------------------------------------------------------------------------
+# User queries
+# ---------------------------------------------------------------------------
+
+
+def _get_authorized_users(permission, user=None):
+    user = _resolve_user(user)
+    if user is None or getattr(user, "is_anonymous", False):
+        return Dojo_User.objects.none()
+    if _is_unrestricted(user, permission_to_action(permission)) or user.is_staff:
+        return Dojo_User.objects.all().order_by("first_name", "last_name")
+    return Dojo_User.objects.filter(pk=user.pk)
+
+
+register_auth_filter("user.get_authorized_users", _get_authorized_users)
+
+
+def _get_authorized_users_for_product_type(users, product_type, permission):
+    if users is None:
+        users = Dojo_User.objects.all()
+    user = get_current_user()
+    if user is None or getattr(user, "is_anonymous", False):
+        return users.none()
+    if _is_unrestricted(user, permission_to_action(permission)) or user.is_staff:
+        return users
+    return users.none()
+
+
+register_auth_filter("user.get_authorized_users_for_product_type", _get_authorized_users_for_product_type)
+
+
+def _get_authorized_users_for_product_and_product_type(users, product, permission):
+    if users is None:
+        users = Dojo_User.objects.all()
+    user = get_current_user()
+    if user is None or getattr(user, "is_anonymous", False):
+        return users.none()
+    if _is_unrestricted(user, permission_to_action(permission)) or user.is_staff:
+        return users
+    return users.none()
+
+
+register_auth_filter("user.get_authorized_users_for_product_and_product_type", _get_authorized_users_for_product_and_product_type)
diff --git a/dojo/authorization/roles_permissions.py b/dojo/authorization/roles_permissions.py
index 0262719b413..f1989477287 100644
--- a/dojo/authorization/roles_permissions.py
+++ b/dojo/authorization/roles_permissions.py
@@ -1,7 +1,47 @@
-from enum import IntEnum
+from enum import IntEnum, StrEnum
+
+
+class Action(StrEnum):
+
+    """
+    Legacy permission actions. The fine-grained Permissions enum below is
+    preserved so existing call sites (`@user_is_authorized(Permissions.X, …)`)
+    keep compiling, but every check now flattens to one of these intents:
+
+      * View          — read-only access to an object (membership in
+                        authorized_users, or staff/superuser bypass)
+      * Edit / Add    — mutating an existing object or creating one
+                        (membership in authorized_users + staff bypass)
+      * Delete        — destroying an object (staff/superuser only)
+      * Import        — bulk ingest of scan results (staff bypass + per-product
+                        membership)
+      * StaffOnly     — administrative actions like member management or
+                        configuration changes
+      * SuperuserOnly — system-wide changes that legacy never delegated
+
+    The role hierarchy (Reader / Writer / Maintainer / Owner) does not exist
+    in this model; per-product distinctions collapse to membership.
+    """
+
+    View = "view"
+    Add = "add"
+    Edit = "edit"
+    Delete = "delete"
+    Import = "import"
+    StaffOnly = "staff_only"
+    SuperuserOnly = "superuser_only"
 
 
 class Roles(IntEnum):
+
+    """
+    Preserved for backward compatibility. Legacy authorization no longer
+    branches on roles — these values now act as labels only. The membership
+    tables (Product_Member, Product_Type_Member, Global_Role) exist as inert
+    data tables that the dojo-pro plugin can adopt; nothing in dojo/ reads
+    role assignments after the legacy rewrite.
+    """
+
     Reader = 5
     API_Importer = 1
     Writer = 2
@@ -131,360 +171,157 @@ def has_value(cls, value):
     @classmethod
     def get_engagement_permissions(cls):
         return {
-            Permissions.Engagement_View,
-            Permissions.Engagement_Edit,
-            Permissions.Engagement_Delete,
-            Permissions.Risk_Acceptance,
-            Permissions.Test_Add,
-            Permissions.Import_Scan_Result,
-            Permissions.Note_Add,
-            Permissions.Note_Delete,
-            Permissions.Note_Edit,
-            Permissions.Note_View_History,
+            "view",
+            "edit",
+            "delete",
+            "add",
+            "import",
         }.union(cls.get_test_permissions())
 
     @classmethod
     def get_test_permissions(cls):
         return {
-            Permissions.Test_View,
-            Permissions.Test_Edit,
-            Permissions.Test_Delete,
-            Permissions.Finding_Add,
-            Permissions.Import_Scan_Result,
-            Permissions.Note_Add,
-            Permissions.Note_Delete,
-            Permissions.Note_Edit,
-            Permissions.Note_View_History,
+            "view",
+            "edit",
+            "delete",
+            "add",
+            "import",
         }.union(cls.get_finding_permissions())
 
     @classmethod
     def get_finding_permissions(cls):
         return {
-            Permissions.Finding_View,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Add,
-            Permissions.Import_Scan_Result,
-            Permissions.Finding_Delete,
-            Permissions.Note_Add,
-            Permissions.Risk_Acceptance,
-            Permissions.Note_Delete,
-            Permissions.Note_Edit,
-            Permissions.Note_View_History,
+            "view",
+            "edit",
+            "add",
+            "import",
+            "delete",
         }.union(cls.get_finding_group_permissions())
 
     @classmethod
     def get_finding_group_permissions(cls):
         return {
-            Permissions.Finding_Group_View,
-            Permissions.Finding_Group_Edit,
-            Permissions.Finding_Group_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
     @classmethod
     def get_location_permissions(cls):
         return {
-            Permissions.Location_View,
-            Permissions.Location_Edit,
-            Permissions.Location_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
     @classmethod
     def get_product_member_permissions(cls):
         return {
-            Permissions.Product_View,
-            Permissions.Product_Manage_Members,
-            Permissions.Product_Member_Delete,
+            "view",
+            "staff_only",
+            "delete",
         }
 
     @classmethod
     def get_product_type_member_permissions(cls):
         return {
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Manage_Members,
-            Permissions.Product_Type_Member_Delete,
+            "view",
+            "staff_only",
+            "delete",
         }
 
     @classmethod
     def get_product_group_permissions(cls):
         return {
-            Permissions.Product_Group_View,
-            Permissions.Product_Group_Edit,
-            Permissions.Product_Group_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
     @classmethod
     def get_product_type_group_permissions(cls):
         return {
-            Permissions.Product_Type_Group_View,
-            Permissions.Product_Type_Group_Edit,
-            Permissions.Product_Type_Group_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
     @classmethod
     def get_group_permissions(cls):
         return {
-            Permissions.Group_View,
-            Permissions.Group_Member_Delete,
-            Permissions.Group_Manage_Members,
-            Permissions.Group_Add_Owner,
-            Permissions.Group_Edit,
-            Permissions.Group_Delete,
+            "view",
+            "delete",
+            "staff_only",
+            "edit",
         }
 
     @classmethod
     def get_group_member_permissions(cls):
         return {
-            Permissions.Group_View,
-            Permissions.Group_Manage_Members,
-            Permissions.Group_Member_Delete,
+            "view",
+            "staff_only",
+            "delete",
         }
 
     @classmethod
     def get_language_permissions(cls):
         return {
-            Permissions.Language_View,
-            Permissions.Language_Edit,
-            Permissions.Language_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
     @classmethod
     def get_technology_permissions(cls):
         return {
-            Permissions.Technology_View,
-            Permissions.Technology_Edit,
-            Permissions.Technology_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
     @classmethod
     def get_product_api_scan_configuration_permissions(cls):
         return {
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_API_Scan_Configuration_Edit,
-            Permissions.Product_API_Scan_Configuration_Delete,
+            "view",
+            "edit",
+            "delete",
         }
 
 
 def get_roles_with_permissions():
     return {
         Roles.Reader: {
-            Permissions.Product_Type_View,
-            Permissions.Product_View,
-            Permissions.Engagement_View,
-            Permissions.Test_View,
-            Permissions.Finding_View,
-            Permissions.Finding_Group_View,
-            Permissions.Location_View,
-            Permissions.Component_View,
-            Permissions.Note_Add,
-            Permissions.Product_Group_View,
-            Permissions.Product_Type_Group_View,
-            Permissions.Group_View,
-            Permissions.Language_View,
-            Permissions.Technology_View,
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_Tracking_Files_View,
+            "view",
+            "add",
         },
         Roles.API_Importer: {
-            Permissions.Product_Type_View,
-            Permissions.Product_View,
-            Permissions.Engagement_View,
-            Permissions.Engagement_Add,
-            Permissions.Engagement_Edit,
-            Permissions.Test_View,
-            Permissions.Test_Edit,
-            Permissions.Finding_View,
-            Permissions.Finding_Group_View,
-            Permissions.Location_View,
-            Permissions.Component_View,
-            Permissions.Product_Group_View,
-            Permissions.Product_Type_Group_View,
-            Permissions.Technology_View,
-            Permissions.Import_Scan_Result,
+            "view",
+            "add",
+            "edit",
+            "import",
         },
         Roles.Writer: {
-            Permissions.Product_Type_View,
-            Permissions.Product_View,
-            Permissions.Engagement_View,
-            Permissions.Engagement_Add,
-            Permissions.Engagement_Edit,
-            Permissions.Risk_Acceptance,
-            Permissions.Test_View,
-            Permissions.Test_Add,
-            Permissions.Test_Edit,
-            Permissions.Finding_View,
-            Permissions.Finding_Add,
-            Permissions.Import_Scan_Result,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Group_View,
-            Permissions.Finding_Group_Add,
-            Permissions.Finding_Group_Edit,
-            Permissions.Finding_Group_Delete,
-            Permissions.Location_View,
-            Permissions.Location_Add,
-            Permissions.Location_Edit,
-            Permissions.Benchmark_Edit,
-            Permissions.Component_View,
-            Permissions.Note_View_History,
-            Permissions.Note_Edit,
-            Permissions.Note_Add,
-            Permissions.Product_Group_View,
-            Permissions.Product_Type_Group_View,
-            Permissions.Group_View,
-            Permissions.Language_View,
-            Permissions.Language_Add,
-            Permissions.Language_Edit,
-            Permissions.Language_Delete,
-            Permissions.Technology_View,
-            Permissions.Technology_Add,
-            Permissions.Technology_Edit,
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_Tracking_Files_View,
+            "view",
+            "add",
+            "edit",
+            "import",
+            "delete",
         },
         Roles.Maintainer: {
-            Permissions.Product_Type_Add_Product,
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Member_Delete,
-            Permissions.Product_Type_Manage_Members,
-            Permissions.Product_Type_Edit,
-            Permissions.Product_View,
-            Permissions.Product_Member_Delete,
-            Permissions.Product_Manage_Members,
-            Permissions.Product_Configure_Notifications,
-            Permissions.Product_Edit,
-            Permissions.Engagement_View,
-            Permissions.Engagement_Add,
-            Permissions.Engagement_Edit,
-            Permissions.Engagement_Delete,
-            Permissions.Risk_Acceptance,
-            Permissions.Test_View,
-            Permissions.Test_Add,
-            Permissions.Test_Edit,
-            Permissions.Test_Delete,
-            Permissions.Finding_View,
-            Permissions.Finding_Add,
-            Permissions.Import_Scan_Result,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Delete,
-            Permissions.Finding_Group_View,
-            Permissions.Finding_Group_Add,
-            Permissions.Finding_Group_Edit,
-            Permissions.Finding_Group_Delete,
-            Permissions.Location_View,
-            Permissions.Location_Add,
-            Permissions.Location_Edit,
-            Permissions.Location_Delete,
-            Permissions.Benchmark_Edit,
-            Permissions.Benchmark_Delete,
-            Permissions.Component_View,
-            Permissions.Note_View_History,
-            Permissions.Note_Edit,
-            Permissions.Note_Add,
-            Permissions.Note_Delete,
-            Permissions.Product_Group_View,
-            Permissions.Product_Group_Add,
-            Permissions.Product_Group_Edit,
-            Permissions.Product_Group_Delete,
-            Permissions.Product_Type_Group_View,
-            Permissions.Product_Type_Group_Add,
-            Permissions.Product_Type_Group_Edit,
-            Permissions.Product_Type_Group_Delete,
-            Permissions.Group_View,
-            Permissions.Group_Edit,
-            Permissions.Group_Manage_Members,
-            Permissions.Group_Member_Delete,
-            Permissions.Language_View,
-            Permissions.Language_Add,
-            Permissions.Language_Edit,
-            Permissions.Language_Delete,
-            Permissions.Technology_View,
-            Permissions.Technology_Add,
-            Permissions.Technology_Edit,
-            Permissions.Technology_Delete,
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_API_Scan_Configuration_Add,
-            Permissions.Product_API_Scan_Configuration_Edit,
-            Permissions.Product_API_Scan_Configuration_Delete,
-            Permissions.Product_Tracking_Files_View,
-            Permissions.Product_Tracking_Files_Add,
-            Permissions.Product_Tracking_Files_Edit,
-            Permissions.Product_Tracking_Files_Delete,
+            "add",
+            "view",
+            "delete",
+            "staff_only",
+            "edit",
+            "import",
         },
         Roles.Owner: {
-            Permissions.Product_Type_Add_Product,
-            Permissions.Product_Type_View,
-            Permissions.Product_Type_Member_Delete,
-            Permissions.Product_Type_Manage_Members,
-            Permissions.Product_Type_Member_Add_Owner,
-            Permissions.Product_Type_Edit,
-            Permissions.Product_Type_Delete,
-            Permissions.Product_View,
-            Permissions.Product_Member_Delete,
-            Permissions.Product_Manage_Members,
-            Permissions.Product_Member_Add_Owner,
-            Permissions.Product_Configure_Notifications,
-            Permissions.Product_Edit,
-            Permissions.Product_Delete,
-            Permissions.Engagement_View,
-            Permissions.Engagement_Add,
-            Permissions.Engagement_Edit,
-            Permissions.Engagement_Delete,
-            Permissions.Risk_Acceptance,
-            Permissions.Test_View,
-            Permissions.Test_Add,
-            Permissions.Test_Edit,
-            Permissions.Test_Delete,
-            Permissions.Finding_View,
-            Permissions.Finding_Add,
-            Permissions.Import_Scan_Result,
-            Permissions.Finding_Edit,
-            Permissions.Finding_Delete,
-            Permissions.Finding_Group_View,
-            Permissions.Finding_Group_Add,
-            Permissions.Finding_Group_Edit,
-            Permissions.Finding_Group_Delete,
-            Permissions.Location_View,
-            Permissions.Location_Add,
-            Permissions.Location_Edit,
-            Permissions.Location_Delete,
-            Permissions.Benchmark_Edit,
-            Permissions.Benchmark_Delete,
-            Permissions.Component_View,
-            Permissions.Note_View_History,
-            Permissions.Note_Edit,
-            Permissions.Note_Add,
-            Permissions.Note_Delete,
-            Permissions.Product_Group_View,
-            Permissions.Product_Group_Add,
-            Permissions.Product_Group_Add_Owner,
-            Permissions.Product_Group_Edit,
-            Permissions.Product_Group_Delete,
-            Permissions.Product_Type_Group_View,
-            Permissions.Product_Type_Group_Add,
-            Permissions.Product_Type_Group_Add_Owner,
-            Permissions.Product_Type_Group_Edit,
-            Permissions.Product_Type_Group_Delete,
-            Permissions.Group_View,
-            Permissions.Group_Edit,
-            Permissions.Group_Manage_Members,
-            Permissions.Group_Member_Delete,
-            Permissions.Group_Add_Owner,
-            Permissions.Group_Delete,
-            Permissions.Language_View,
-            Permissions.Language_Add,
-            Permissions.Language_Edit,
-            Permissions.Language_Delete,
-            Permissions.Technology_View,
-            Permissions.Technology_Add,
-            Permissions.Technology_Edit,
-            Permissions.Technology_Delete,
-            Permissions.Product_API_Scan_Configuration_View,
-            Permissions.Product_API_Scan_Configuration_Add,
-            Permissions.Product_API_Scan_Configuration_Edit,
-            Permissions.Product_API_Scan_Configuration_Delete,
-            Permissions.Product_Tracking_Files_View,
-            Permissions.Product_Tracking_Files_Add,
-            Permissions.Product_Tracking_Files_Edit,
-            Permissions.Product_Tracking_Files_Delete,
+            "add",
+            "view",
+            "delete",
+            "staff_only",
+            "edit",
+            "import",
         },
     }
 
@@ -492,6 +329,45 @@ def get_roles_with_permissions():
 def get_global_roles_with_permissions():
     """Extra permissions for global roles, on top of the permissions granted to the "normal" roles above."""
     return {
-        Roles.Maintainer: {Permissions.Product_Type_Add},
-        Roles.Owner: {Permissions.Product_Type_Add},
+        Roles.Maintainer: {"add"},
+        Roles.Owner: {"add"},
     }
+
+
+def permission_to_action(permission):
+    """
+    Map a fine-grained Permissions enum member, action string, or legacy
+    enum-name string (e.g. "Product_Edit") to an Action.
+
+    The suffix-based mapping captures every Permissions name (which all
+    follow the ``_`` convention); the noun is irrelevant
+    because legacy authorization is not noun-aware (the object passed at
+    check time determines the membership scope).
+    """
+    if isinstance(permission, Action):
+        return permission
+
+    if isinstance(permission, str):
+        try:
+            return Action(permission)
+        except ValueError:
+            name = permission
+    else:
+        name = getattr(permission, "name", "") or str(permission)
+
+    if name == "Risk_Acceptance":
+        return Action.Edit
+    if name == "Import_Scan_Result":
+        return Action.Import
+    if name.endswith(("_View", "_View_History")):
+        return Action.View
+    if name.endswith(("_Edit", "_Configure_Notifications")):
+        return Action.Edit
+    if name.endswith("_Delete"):
+        return Action.Delete
+    if name.endswith(("_Add_Product", "_Add")):
+        return Action.Add
+    if "_Manage_" in name or name.endswith("_Add_Owner"):
+        return Action.StaffOnly
+
+    return Action.View
diff --git a/dojo/authorization/template_filters.py b/dojo/authorization/template_filters.py
new file mode 100644
index 00000000000..4e7caf61ba2
--- /dev/null
+++ b/dojo/authorization/template_filters.py
@@ -0,0 +1,47 @@
+import crum
+
+from dojo.authorization.authorization import user_has_configuration_permission as configuration_permission
+from dojo.authorization.authorization import user_has_global_permission, user_has_permission
+from dojo.request_cache import cache_for_request
+
+
+def has_object_permission(obj, permission):
+    # Pass-through to user_has_permission(); permission_to_action() inside the
+    # legacy authorization layer accepts both the new action strings ("view",
+    # "edit", ...) and any leftover Permissions enum names ("Product_Edit", ...).
+    return user_has_permission(crum.get_current_user(), obj, permission)
+
+
+def has_global_permission(permission):
+    return user_has_global_permission(crum.get_current_user(), permission)
+
+
+def has_configuration_permission(permission, request):
+    user = crum.get_current_user() if request is None else crum.get_current_user() or request.user
+    return configuration_permission(user, permission)
+
+
+@cache_for_request
+def get_user_permissions(user):
+    return user.user_permissions.all()
+
+
+def user_has_configuration_permission_without_group(user, codename):
+    permissions = get_user_permissions(user)
+    return any(permission.codename == codename for permission in permissions)
+
+
+@cache_for_request
+def get_group_permissions(group):
+    return group.permissions.all()
+
+
+def group_has_configuration_permission(group, codename):
+    return any(permission.codename == codename for permission in get_group_permissions(group))
+
+
+def user_can_clear_peer_review(finding, user):
+    finding_under_review = finding.under_review
+    user_requesting_review = user == finding.review_requested_by
+    user_is_reviewer = user in finding.reviewers.all()
+    return finding_under_review and (user_requesting_review or user_is_reviewer)
diff --git a/dojo/authorization/url_permissions.py b/dojo/authorization/url_permissions.py
new file mode 100644
index 00000000000..65807ee52c2
--- /dev/null
+++ b/dojo/authorization/url_permissions.py
@@ -0,0 +1,295 @@
+from dojo.models import (
+    App_Analysis,
+    Endpoint,
+    Engagement,
+    Finding,
+    Finding_Group,
+    Product,
+    Product_API_Scan_Configuration,
+    Product_Type,
+    Test,
+)
+
+# ---------------------------------------------------------------------------
+# URL_PERMISSIONS: maps Django URL names to authorization checks.
+#
+# Each key is a URL name (from urls.py).
+# Each value is a list of check tuples. ALL checks in the list must pass.
+#
+# Check tuple formats:
+#   ("object",  ModelClass, "view", "kwarg_name")
+#   ("global",  "view")
+#   ("config",  "permission.string")
+# ---------------------------------------------------------------------------
+
+URL_PERMISSIONS = {
+    # -----------------------------------------------------------------------
+    # Product Type (dojo/product_type/views.py  ->  dojo/organization/urls.py)
+    # -----------------------------------------------------------------------
+    "add_product_type": [("global", "add")],
+    "view_product_type": [("object", Product_Type, "view", "ptid")],
+    "edit_product_type": [("object", Product_Type, "edit", "ptid")],
+    "delete_product_type": [("object", Product_Type, "delete", "ptid")],
+
+    # -----------------------------------------------------------------------
+    # Product (dojo/product/views.py  ->  dojo/asset/urls.py)
+    # -----------------------------------------------------------------------
+    "view_product": [("object", Product, "view", "pid")],
+    "view_product_components": [("object", Product, "view", "pid")],
+    "view_product_metrics": [("object", Product, "view", "pid")],
+    "async_burndown_metrics": [("object", Product, "view", "pid")],
+    "view_engagements": [("object", Product, "view", "pid")],
+    "edit_product": [("object", Product, "edit", "pid")],
+    "delete_product": [("object", Product, "delete", "pid")],
+    "new_eng_for_prod": [("object", Product, "add", "pid")],
+    "new_eng_for_prod_cicd": [("object", Product, "add", "pid")],
+    "new_tech_for_prod": [("object", Product, "add", "pid")],
+    "edit_technology": [("object", App_Analysis, "edit", "tid")],
+    "delete_technology": [("object", App_Analysis, "delete", "tid")],
+    "add_meta_data": [("object", Product, "edit", "pid")],
+    "edit_meta_data": [("object", Product, "edit", "pid")],
+    "edit_notifications": [("object", Product, "view", "pid")],
+    "engagement_presets": [("object", Product, "view", "pid")],
+    "edit_engagement_presets": [("object", Product, "edit", "pid")],
+    "add_engagement_presets": [("object", Product, "edit", "pid")],
+    "delete_engagement_presets": [("object", Product, "edit", "pid")],
+    "add_api_scan_configuration": [("object", Product, "add", "pid")],
+    "view_api_scan_configurations": [("object", Product, "view", "pid")],
+    "edit_api_scan_configuration": [("object", Product_API_Scan_Configuration, "edit", "pascid")],
+    "delete_api_scan_configuration": [("object", Product_API_Scan_Configuration, "delete", "pascid")],
+
+    # -----------------------------------------------------------------------
+    # Engagement (dojo/engagement/views.py  ->  dojo/engagement/urls.py)
+    # -----------------------------------------------------------------------
+    "edit_engagement": [("object", Engagement, "edit", "eid")],
+    "delete_engagement": [("object", Engagement, "delete", "eid")],
+    "copy_engagement": [("object", Engagement, "edit", "eid")],
+    "add_tests": [("object", Engagement, "add", "eid")],
+    "close_engagement": [("object", Engagement, "edit", "eid")],
+    "engagement_unlink_jira": [("object", Engagement, "edit", "eid")],
+    "reopen_engagement": [("object", Engagement, "edit", "eid")],
+    "complete_checklist": [("object", Engagement, "edit", "eid")],
+    "add_risk_acceptance": [("object", Engagement, "edit", "eid")],
+    "view_risk_acceptance": [("object", Engagement, "view", "eid")],
+    "edit_risk_acceptance": [("object", Engagement, "edit", "eid")],
+    "expire_risk_acceptance": [("object", Engagement, "edit", "eid")],
+    "reinstate_risk_acceptance": [("object", Engagement, "edit", "eid")],
+    "delete_risk_acceptance": [("object", Engagement, "edit", "eid")],
+    "download_risk_acceptance": [("object", Engagement, "view", "eid")],
+    "upload_threatmodel": [("object", Engagement, "edit", "eid")],
+    "view_threatmodel": [("object", Engagement, "view", "eid")],
+    "engagement_ics": [("object", Engagement, "view", "eid")],
+
+    # -----------------------------------------------------------------------
+    # Test (dojo/test/views.py  ->  dojo/test/urls.py)
+    # -----------------------------------------------------------------------
+    "edit_test": [("object", Test, "edit", "tid")],
+    "delete_test": [("object", Test, "delete", "tid")],
+    "copy_test": [("object", Test, "edit", "tid")],
+    "test_ics": [("object", Test, "view", "tid")],
+    "add_finding_from_template": [("object", Test, "add", "tid")],
+    "search": [("object", Test, "view", "tid")],
+
+    # -----------------------------------------------------------------------
+    # Finding (dojo/finding/views.py  ->  dojo/finding/urls.py)
+    # -----------------------------------------------------------------------
+    "close_finding": [("object", Finding, "edit", "fid")],
+    "verify_finding": [("object", Finding, "edit", "fid")],
+    "defect_finding_review": [("object", Finding, "edit", "fid")],
+    "reopen_finding": [("object", Finding, "edit", "fid")],
+    "copy_finding": [("object", Finding, "edit", "fid")],
+    "remediation_date": [("object", Finding, "edit", "fid")],
+    "touch_finding": [("object", Finding, "edit", "fid")],
+    "simple_risk_accept_finding": [("object", Finding, "edit", "fid")],
+    "risk_unaccept_finding": [("object", Finding, "edit", "fid")],
+    "request_finding_review": [("object", Finding, "view", "fid")],
+    "clear_finding_review": [("object", Finding, "edit", "fid")],
+    "mktemplate": [("global", "add")],
+    "find_template_to_apply": [("object", Finding, "edit", "fid")],
+    "choose_finding_template_options": [("object", Finding, "edit", "fid")],
+    "apply_template_to_finding": [("object", Finding, "edit", "fid")],
+    "merge_finding": [("object", Product, "edit", "pid")],
+    "merge_finding_product": [("object", Product, "edit", "pid")],
+    "mark_finding_duplicate": [("object", Finding, "edit", "original_id")],
+    "reset_finding_duplicate_status": [("object", Finding, "edit", "duplicate_id")],
+    "set_finding_as_original": [("object", Finding, "edit", "finding_id")],
+    "finding_unlink_jira": [("object", Finding, "edit", "fid")],
+    "finding_push_to_jira": [("object", Finding, "edit", "fid")],
+
+    # Finding templates
+    "templates": [("global", "edit")],
+    "export_template": [("global", "edit")],
+    "add_template": [("global", "add")],
+    "edit_template": [("global", "edit")],
+    "delete_template": [("global", "delete")],
+
+    # -----------------------------------------------------------------------
+    # Finding Group (dojo/finding_group/views.py  ->  dojo/finding_group/urls.py)
+    # -----------------------------------------------------------------------
+    "view_finding_group": [("object", Finding_Group, "view", "fgid")],
+    "delete_finding_group": [("object", Finding_Group, "delete", "fgid")],
+    "finding_group_push_to_jira": [("object", Finding_Group, "edit", "fgid")],
+    "finding_group_unlink_jira": [("object", Finding_Group, "edit", "fgid")],
+
+    # -----------------------------------------------------------------------
+    # Endpoint (dojo/endpoint/views.py  ->  dojo/endpoint/urls.py)
+    # -----------------------------------------------------------------------
+    "view_endpoint": [("object", Endpoint, "view", "eid")],
+    "view_endpoint_host": [("object", Endpoint, "view", "eid")],
+    "edit_endpoint": [("object", Endpoint, "edit", "eid")],
+    "add_endpoint": [("object", Product, "add", "pid")],
+    "delete_endpoint": [("object", Endpoint, "delete", "eid")],
+    "add_endpoint_meta_data": [("object", Endpoint, "edit", "eid")],
+    "edit_endpoint_meta_data": [("object", Endpoint, "edit", "eid")],
+    "endpoints_status_bulk": [("object", Finding, "edit", "fid")],
+    "import_endpoint_meta": [("object", Product, "edit", "pid")],
+    "endpoint_report": [("object", Endpoint, "view", "eid")],
+    "endpoint_host_report": [("object", Endpoint, "view", "eid")],
+
+    # -----------------------------------------------------------------------
+    # URL / Location UI (dojo/url/ui/views.py  ->  dojo/url/ui/urls.py)
+    #
+    # These URL names overlap with the endpoint module above. Since Django
+    # uses the last-registered pattern for reverse() and the middleware reads
+    # view_kwargs from the matched pattern, the kwarg names from the actually
+    # matched URL are used. The endpoint entries above use "eid"; if the
+    # url/ui pattern matched instead, "location_id" will be present and the
+    # middleware will fall back (skip checks where the kwarg is missing).
+    #
+    # Unique URL names from url/ui:
+    # -----------------------------------------------------------------------
+    "add_endpoint_to_product": [("object", Product, "add", "product_id")],
+    "add_endpoint_to_finding": [("object", Product, "add", "finding_id")],
+
+    # -----------------------------------------------------------------------
+    # Reports (dojo/reports/views.py  ->  dojo/reports/urls.py)
+    # -----------------------------------------------------------------------
+    "product_type_report": [("object", Product_Type, "view", "ptid")],
+    "product_report": [("object", Product, "view", "pid")],
+    "product_endpoint_report": [("object", Product, "view", "pid")],
+    "engagement_report": [("object", Engagement, "view", "eid")],
+    "test_report": [("object", Test, "view", "tid")],
+
+    # -----------------------------------------------------------------------
+    # Tool Product (dojo/tool_product/views.py  ->  dojo/tool_product/urls.py)
+    # -----------------------------------------------------------------------
+    "new_tool_product": [("object", Product, "edit", "pid")],
+    "all_tool_product": [("object", Product, "edit", "pid")],
+    "edit_tool_product": [("object", Product, "edit", "pid")],
+    "delete_tool_product": [("object", Product, "edit", "pid")],
+
+    # -----------------------------------------------------------------------
+    # Tool Type (dojo/tool_type/views.py  ->  dojo/tool_type/urls.py)
+    # -----------------------------------------------------------------------
+    "add_tool_type": [("config", "dojo.add_tool_type")],
+    "edit_tool_type": [("config", "dojo.change_tool_type")],
+    "tool_type": [("config", "dojo.view_tool_type")],
+
+    # -----------------------------------------------------------------------
+    # Tool Config (dojo/tool_config/views.py  ->  dojo/tool_config/urls.py)
+    # -----------------------------------------------------------------------
+    "add_tool_config": [("config", "dojo.add_tool_configuration")],
+    "edit_tool_config": [("config", "dojo.change_tool_configuration")],
+    "tool_config": [("config", "dojo.view_tool_configuration")],
+
+    # -----------------------------------------------------------------------
+    # Benchmark (dojo/benchmark/views.py  ->  dojo/benchmark/urls.py)
+    # -----------------------------------------------------------------------
+    "view_product_benchmark": [("object", Product, "edit", "pid")],
+    "edit_benchmark": [("object", Product, "edit", "pid")],
+    "delete_product_benchmark": [("object", Product, "delete", "pid")],
+    "update_product_benchmark": [("object", Product, "edit", "pid")],
+    "update_product_benchmark_summary": [("object", Product, "edit", "pid")],
+
+    # -----------------------------------------------------------------------
+    # Object / Tracked Files (dojo/object/views.py  ->  dojo/object/urls.py)
+    # -----------------------------------------------------------------------
+    "new_object": [("object", Product, "add", "pid")],
+    "view_objects": [("object", Product, "view", "pid")],
+    "edit_object": [("object", Product, "edit", "pid")],
+    "delete_object": [("object", Product, "delete", "pid")],
+
+    # -----------------------------------------------------------------------
+    # Note Type (dojo/note_type/views.py  ->  dojo/note_type/urls.py)
+    # -----------------------------------------------------------------------
+    "note_type": [("config", "dojo.view_note_type")],
+    "edit_note_type": [("config", "dojo.change_note_type")],
+    "disable_note_type": [("config", "dojo.change_note_type")],
+    "enable_note_type": [("config", "dojo.change_note_type")],
+    "add_note_type": [("config", "dojo.add_note_type")],
+
+    # -----------------------------------------------------------------------
+    # SLA Config (dojo/sla_config/views.py  ->  dojo/sla_config/urls.py)
+    # -----------------------------------------------------------------------
+    "new_sla_config": [("config", "dojo.add_sla_configuration")],
+    "edit_sla_config": [("config", "dojo.change_sla_configuration")],
+    "sla_config": [("config", "dojo.view_sla_configuration")],
+
+    # -----------------------------------------------------------------------
+    # Regulations (dojo/regulations/views.py  ->  dojo/regulations/urls.py)
+    # -----------------------------------------------------------------------
+    "new_regulation": [("config", "dojo.add_regulation")],
+    "edit_regulations": [("config", "dojo.change_regulation")],
+
+    # -----------------------------------------------------------------------
+    # Development Environment (dojo/development_environment/views.py)
+    # -----------------------------------------------------------------------
+    "add_dev_env": [("config", "dojo.add_development_environment")],
+    "edit_dev_env": [("config", "dojo.change_development_environment")],
+
+    # -----------------------------------------------------------------------
+    # GitHub Issue Link (dojo/github_issue_link/views.py)
+    # -----------------------------------------------------------------------
+    "add_github": [("config", "dojo.add_github_conf")],
+    "github": [("config", "dojo.view_github_conf")],
+    "delete_github": [("config", "dojo.delete_github_conf")],
+
+    # -----------------------------------------------------------------------
+    # Test Type (dojo/test_type/views.py  ->  dojo/test_type/urls.py)
+    # -----------------------------------------------------------------------
+    "add_test_type": [("config", "dojo.add_test_type")],
+    "edit_test_type": [("config", "dojo.change_test_type")],
+
+    # -----------------------------------------------------------------------
+    # Announcement (dojo/announcement/views.py)
+    # -----------------------------------------------------------------------
+    "configure_announcement": [("config", "dojo.change_announcement")],
+
+    # -----------------------------------------------------------------------
+    # Banner (dojo/banner/views.py)
+    # -----------------------------------------------------------------------
+    "configure_banner": [("config", "dojo.change_bannerconf")],
+
+    # -----------------------------------------------------------------------
+    # User (dojo/user/views.py  ->  dojo/user/urls.py)
+    # -----------------------------------------------------------------------
+    "users": [("config", "auth.view_user")],
+    "add_user": [("config", "auth.add_user")],
+    "view_user": [("config", "auth.view_user")],
+    "edit_user": [("config", "auth.change_user")],
+    "delete_user": [("config", "auth.delete_user")],
+    "edit_user_permissions": [("config", "auth.change_permission")],
+
+    # -----------------------------------------------------------------------
+    # Survey / Questionnaire (dojo/survey/views.py  ->  dojo/survey/urls.py)
+    # -----------------------------------------------------------------------
+    # Engagement-scoped questionnaire views
+    "delete_engagement_survey": [("object", Engagement, "edit", "eid")],
+    "assign_questionnaire": [("object", Engagement, "edit", "eid")],
+    "view_questionnaire": [("object", Engagement, "view", "eid")],
+    "add_questionnaire": [("object", Engagement, "edit", "eid")],
+
+    # Global questionnaire management
+    "edit_questionnaire": [("config", "dojo.change_engagement_survey")],
+    "delete_questionnaire": [("config", "dojo.delete_engagement_survey")],
+    "create_questionnaire": [("config", "dojo.add_engagement_survey")],
+    "questionnaire": [("config", "dojo.view_engagement_survey")],
+    "questions": [("config", "dojo.view_question")],
+    "create_question": [("config", "dojo.add_question")],
+    "edit_question": [("config", "dojo.change_question")],
+    "add_choices": [("config", "dojo.change_question")],
+    "add_empty_questionnaire": [("config", "dojo.add_engagement_survey")],
+    "view_empty_survey": [("config", "dojo.view_engagement_survey")],
+    "delete_empty_questionnaire": [("config", "dojo.delete_engagement_survey")],
+    "delete_general_questionnaire": [("config", "dojo.delete_engagement_survey")],
+}
diff --git a/dojo/banner/views.py b/dojo/banner/views.py
index dcdccc77cc5..1bdf8ce2e68 100644
--- a/dojo/banner/views.py
+++ b/dojo/banner/views.py
@@ -5,9 +5,6 @@
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 
-from dojo.authorization.authorization_decorators import (
-    user_is_configuration_authorized,
-)
 from dojo.forms import LoginBanner
 from dojo.models import BannerConf
 from dojo.utils import add_breadcrumb
@@ -15,7 +12,6 @@
 logger = logging.getLogger(__name__)
 
 
-@user_is_configuration_authorized("dojo.change_bannerconf")
 def configure_banner(request):
     banner_config = get_object_or_404(BannerConf, id=1)
     if request.method == "POST":
diff --git a/dojo/benchmark/views.py b/dojo/benchmark/views.py
index c694955136c..b1dc065692e 100644
--- a/dojo/benchmark/views.py
+++ b/dojo/benchmark/views.py
@@ -8,8 +8,6 @@
 from django.urls import reverse
 from django.utils.translation import gettext as _
 
-from dojo.authorization.authorization_decorators import user_is_authorized
-from dojo.authorization.roles_permissions import Permissions
 from dojo.forms import Benchmark_Product_SummaryForm, DeleteBenchmarkForm
 from dojo.models import (
     Benchmark_Category,
@@ -39,7 +37,6 @@ def add_benchmark(queryset, product):
     Benchmark_Product.objects.bulk_create(requirements)
 
 
-@user_is_authorized(Product, Permissions.Benchmark_Edit, "pid")
 def update_benchmark(request, pid, _type):
     if request.method == "POST":
         bench_id = request.POST.get("bench_id")
@@ -88,7 +85,6 @@ def update_benchmark(request, pid, _type):
     )
 
 
-@user_is_authorized(Product, Permissions.Benchmark_Edit, "pid")
 def update_benchmark_summary(request, pid, _type, summary):
     if request.method == "POST":
         product = get_object_or_404(Product, id=pid)
@@ -190,7 +186,6 @@ def score_asvs(product, benchmark_type):
     benchmark_product_summary.save()
 
 
-@user_is_authorized(Product, Permissions.Benchmark_Edit, "pid")
 def benchmark_view(request, pid, benchmark_type, cat=None):
     product = get_object_or_404(Product, id=pid)
     benchmark_type = get_object_or_404(Benchmark_Type, id=benchmark_type)
@@ -289,7 +284,6 @@ def benchmark_view(request, pid, benchmark_type, cat=None):
     )
 
 
-@user_is_authorized(Product, Permissions.Benchmark_Delete, "pid")
 def delete(request, pid, benchmark_type):
     product = get_object_or_404(Product, id=pid)
     benchmark_product_summary = get_object_or_404(
diff --git a/dojo/components/views.py b/dojo/components/views.py
index 586a0573119..28e6f720ea8 100644
--- a/dojo/components/views.py
+++ b/dojo/components/views.py
@@ -4,7 +4,6 @@
 from django.db.models.expressions import Value
 from django.shortcuts import render
 
-from dojo.authorization.roles_permissions import Permissions
 from dojo.components.sql_group_concat import Sql_GroupConcat
 from dojo.filters import ComponentFilter, ComponentFilterWithoutObjectLookups
 from dojo.finding.queries import get_authorized_findings
@@ -17,7 +16,7 @@ def components(request):
     # Get components ordered by component_name and concat component versions
     # to the same row
 
-    component_query = get_authorized_findings(Permissions.Finding_View)
+    component_query = get_authorized_findings("view")
 
     if connection.vendor == "postgresql":
         component_query = (
diff --git a/dojo/context_processors.py b/dojo/context_processors.py
index 93d8607b4c0..718ecb6460a 100644
--- a/dojo/context_processors.py
+++ b/dojo/context_processors.py
@@ -3,6 +3,7 @@
 # import the settings file
 from django.conf import settings
 from django.contrib import messages
+from django.urls import NoReverseMatch, reverse
 
 from dojo.announcement.os_message import get_os_banner
 from dojo.labels import get_labels
@@ -12,7 +13,6 @@
 def globalize_vars(request):
     # return the value you want as a dictionnary. you may add multiple values in there.
     context = {
-        "SHOW_LOGIN_FORM": settings.SHOW_LOGIN_FORM,
         "FORGOT_PASSWORD": settings.FORGOT_PASSWORD,
         "FORGOT_USERNAME": settings.FORGOT_USERNAME,
         "CLASSIC_AUTH_ENABLED": settings.CLASSIC_AUTH_ENABLED,
@@ -40,12 +40,37 @@ def globalize_vars(request):
         for banner in request.session.pop("_product_banners", []):
             additional_banners.append(banner)
 
+    if _should_show_ui_toggle_banner(request):
+        try:
+            profile_url = reverse("view_profile")
+        except NoReverseMatch:
+            profile_url = ""
+        additional_banners.append({
+            "source": "ui_toggle",
+            "message": "A redesigned UI is available as a beta opt-in. It will become the default on September 8th in the 2.62.0 release.",
+            "style": "info",
+            "url": profile_url,
+            "link_text": "Enable it in your profile.",
+            "expanded_html": None,
+        })
+
     if additional_banners:
         context["additional_banners"] = additional_banners
 
     return context
 
 
+def _should_show_ui_toggle_banner(request):
+    user = getattr(request, "user", None)
+    if user is None or not getattr(user, "is_authenticated", False):
+        return False
+    contact = getattr(user, "usercontactinfo", None)
+    # Show the banner whenever the authenticated user has not opted into the
+    # Tailwind UI — that includes users without a contact info row at all
+    # (those users get the classic UI by default in UIPreferenceLoader).
+    return not (contact is not None and getattr(contact, "ui_use_tailwind", False))
+
+
 def bind_system_settings(request):
     """Load system settings and display warning if there's a database error."""
     try:
diff --git a/dojo/db_migrations/0267_usercontactinfo_ui_use_tailwind.py b/dojo/db_migrations/0267_usercontactinfo_ui_use_tailwind.py
new file mode 100644
index 00000000000..978a2f30b80
--- /dev/null
+++ b/dojo/db_migrations/0267_usercontactinfo_ui_use_tailwind.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.13 on 2026-04-28 20:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dojo', '0266_remove_credential_manager'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='usercontactinfo',
+            name='ui_use_tailwind',
+            field=models.BooleanField(default=False, help_text='Opt in to the new Tailwind-based UI. Leave off for the classic UI.', verbose_name='Use new UI (beta)'),
+        ),
+    ]
diff --git a/dojo/db_migrations/0268_release_authorization_to_pro.py b/dojo/db_migrations/0268_release_authorization_to_pro.py
new file mode 100644
index 00000000000..baa12899415
--- /dev/null
+++ b/dojo/db_migrations/0268_release_authorization_to_pro.py
@@ -0,0 +1,240 @@
+"""
+Release the authorization layer from dojo to pro.
+
+Pairs with ``pro.0052_pro_authorization_takeover`` (``run_before``): Pro
+adopts the seven RBAC tables and ``Dojo_Group`` into ``pro`` state via
+state-only ``CreateModel`` operations, adds the relocated
+``default_group`` / ``default_group_role`` / ``default_group_email_pattern``
+columns onto ``pro_enhanced_system_settings``, and copies the existing
+default-group values out of ``dojo_system_settings``. Ordering ensures
+Pro adopts the tables and copies the values *before* this migration
+flips them to ``managed=False`` and drops the source columns.
+
+Five concerns folded into a single migration:
+
+1. **Re-introduce the legacy ``authorized_users`` M2M** on Product /
+   Product_Type. OS-only deployments authorize against this M2M (plus
+   ``is_superuser`` / ``is_staff``); Pro deployments keep using RBAC.
+
+2. **Backfill ``authorized_users`` from RBAC** (``RunPython``). Translates
+   ``Product_Member`` / ``Product_Type_Member`` rows, ``Product_Group`` /
+   ``Product_Type_Group`` (flattened through ``Dojo_Group_Member``), and
+   ``Global_Role`` (Owner → ``is_superuser``, elevated → ``is_staff``)
+   into legacy memberships and user-flag values. Idempotent — guarded on
+   the presence of ``dojo_role`` so fresh installs are a no-op. The RBAC
+   tables themselves are NOT modified or dropped — they remain
+   bit-for-bit so Pro can adopt them unchanged.
+
+3. **Drop the legacy M2M accessors on Product / Product_Type.** ``members``
+   and ``authorization_groups`` were post-RBAC convenience accessors layered
+   on top of the through-tables (``Product_Member`` / ``Product_Type_Member``
+   and ``Product_Group`` / ``Product_Type_Group``). The through-tables are
+   owned by Pro and remain the source of truth; the accessors duplicated
+   their data and made it ambiguous which path was canonical.
+
+4. **Flip the eight authorization shells to ``managed=False``** — the
+   seven RBAC models plus ``Dojo_Group``. Pro's ``CreateModel`` in
+   ``pro.0052`` is the ``managed=True`` canonical owner; both states
+   share the same ``db_table`` so no DDL conflicts. The dojo-side classes
+   stay as ``managed=False`` shells in ``dojo/authorization/models.py`` so
+   OS code that imports them keeps resolving and historical pro
+   migrations whose state references ``dojo.dojo_group`` /
+   ``dojo.role`` / etc. (e.g. ``pro.0001_plugiun_consolidation``
+   ``EnhancedDojoGroup.group`` and ``pro.0034_pghistory_for_permissions_models``
+   proxy bases) keep resolving when the executor reloads project state.
+
+5. **Drop the relocated default-group columns** from
+   ``dojo_system_settings``. OS code base must not reference Pro app
+   models, so ``default_group`` (FK to ``Dojo_Group``),
+   ``default_group_role`` (FK to ``Role``), and
+   ``default_group_email_pattern`` relocate onto
+   ``pro.EnhancedSystemSettings``. Pro's matching migration copies the
+   values across before this migration drops the source columns.
+
+The first four concerns are pure state-only operations or RBAC-table
+reads (no DDL on the eight shared tables); the new ``authorized_users``
+M2M and the dropped ``default_group`` columns issue real DDL.
+"""
+
+from django.db import migrations, models
+
+
+def backfill_authorized_users(apps, schema_editor):
+    """Translate RBAC rows into the legacy ``authorized_users`` M2M.
+
+    Forward-only data migration. The RBAC tables themselves are NOT
+    modified — they remain available verbatim so a Pro install can pick
+    them up unchanged via ``pro.0052_pro_authorization_takeover``.
+
+    Mapping:
+      Product_Member.user (any role)        -> Product.authorized_users
+      Product_Type_Member.user (any role)   -> Product_Type.authorized_users
+      Product_Group.group + Dojo_Group_Member.user
+                                            -> Product.authorized_users (flattened)
+      Product_Type_Group.group + Dojo_Group_Member.user
+                                            -> Product_Type.authorized_users (flattened)
+      Global_Role(Owner) for user           -> User.is_superuser = True
+      Global_Role(Owner) via group          -> all group members.is_superuser = True
+      Global_Role(Writer|Maintainer|API_Importer) for user
+                                            -> User.is_staff = True
+      Global_Role(Writer|Maintainer|API_Importer) via group
+                                            -> all group members.is_staff = True
+      Global_Role(Reader)                   -> no global elevation
+                                              (relies on per-product membership)
+    """
+    connection = schema_editor.connection
+    if "dojo_role" not in connection.introspection.table_names():
+        # Fresh install: no RBAC tables. Nothing to do.
+        return
+
+    try:
+        Product = apps.get_model("dojo", "Product")
+        Product_Type = apps.get_model("dojo", "Product_Type")
+        Dojo_User = apps.get_model("dojo", "Dojo_User")
+        Product_Member = apps.get_model("dojo", "Product_Member")
+        Product_Type_Member = apps.get_model("dojo", "Product_Type_Member")
+        Product_Group = apps.get_model("dojo", "Product_Group")
+        Product_Type_Group = apps.get_model("dojo", "Product_Type_Group")
+        Dojo_Group_Member = apps.get_model("dojo", "Dojo_Group_Member")
+        Global_Role = apps.get_model("dojo", "Global_Role")
+    except LookupError:
+        # Models already released from the dojo app state. Nothing to do.
+        return
+
+    # 1. Direct per-product / per-product-type memberships.
+    for product_id, user_id in Product_Member.objects.values_list("product_id", "user_id"):
+        Product.authorized_users.through.objects.get_or_create(
+            product_id=product_id,
+            dojo_user_id=user_id,
+        )
+    for product_type_id, user_id in Product_Type_Member.objects.values_list("product_type_id", "user_id"):
+        Product_Type.authorized_users.through.objects.get_or_create(
+            product_type_id=product_type_id,
+            dojo_user_id=user_id,
+        )
+
+    # 2. Group memberships: flatten Dojo_Group_Member.user into authorized_users.
+    for product_id, group_id in Product_Group.objects.values_list("product_id", "group_id"):
+        member_user_ids = Dojo_Group_Member.objects.filter(group_id=group_id).values_list("user_id", flat=True)
+        for user_id in member_user_ids:
+            Product.authorized_users.through.objects.get_or_create(
+                product_id=product_id,
+                dojo_user_id=user_id,
+            )
+    for product_type_id, group_id in Product_Type_Group.objects.values_list("product_type_id", "group_id"):
+        member_user_ids = Dojo_Group_Member.objects.filter(group_id=group_id).values_list("user_id", flat=True)
+        for user_id in member_user_ids:
+            Product_Type.authorized_users.through.objects.get_or_create(
+                product_type_id=product_type_id,
+                dojo_user_id=user_id,
+            )
+
+    # 3. Global_Role -> is_superuser / is_staff flags.
+    owner_user_ids = list(
+        Global_Role.objects.filter(role__name="Owner", user__isnull=False).values_list("user_id", flat=True),
+    )
+    owner_group_ids = list(
+        Global_Role.objects.filter(role__name="Owner", group__isnull=False).values_list("group_id", flat=True),
+    )
+    owner_user_ids.extend(
+        Dojo_Group_Member.objects.filter(group_id__in=owner_group_ids).values_list("user_id", flat=True),
+    )
+    if owner_user_ids:
+        Dojo_User.objects.filter(id__in=owner_user_ids).update(is_superuser=True)
+
+    elevated_user_ids = list(
+        Global_Role.objects.filter(
+            role__name__in=("Writer", "Maintainer", "API_Importer"),
+            user__isnull=False,
+        ).values_list("user_id", flat=True),
+    )
+    elevated_group_ids = list(
+        Global_Role.objects.filter(
+            role__name__in=("Writer", "Maintainer", "API_Importer"),
+            group__isnull=False,
+        ).values_list("group_id", flat=True),
+    )
+    elevated_user_ids.extend(
+        Dojo_Group_Member.objects.filter(group_id__in=elevated_group_ids).values_list("user_id", flat=True),
+    )
+    if elevated_user_ids:
+        Dojo_User.objects.filter(id__in=elevated_user_ids).update(is_staff=True)
+
+
+def reverse_noop(apps, schema_editor):  # noqa: ARG001
+    # Reverse is a no-op. Backfilled authorized_users membership and
+    # is_superuser / is_staff flags are preserved if this migration is
+    # rolled back; reverse cannot reliably distinguish migrated entries
+    # from manually-added ones, and the source RBAC tables are still
+    # intact for a forward re-run anyway.
+    return
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("dojo", "0267_usercontactinfo_ui_use_tailwind"),
+    ]
+
+    operations = [
+        # Re-introduce the legacy ``authorized_users`` M2M and backfill it
+        # from the RBAC tables.
+        migrations.AddField(
+            model_name="product",
+            name="authorized_users",
+            field=models.ManyToManyField(blank=True, related_name="authorized_products", to="dojo.dojo_user"),
+        ),
+        migrations.AddField(
+            model_name="product_type",
+            name="authorized_users",
+            field=models.ManyToManyField(blank=True, related_name="authorized_product_types", to="dojo.dojo_user"),
+        ),
+        migrations.RunPython(backfill_authorized_users, reverse_noop),
+        migrations.SeparateDatabaseAndState(
+            state_operations=[
+                # Drop the redundant post-RBAC M2M accessors. The
+                # through-tables (Product_Member / Product_Group, etc.) are
+                # owned by Pro and are the canonical source of truth.
+                migrations.RemoveField(model_name="product_type", name="members"),
+                migrations.RemoveField(model_name="product_type", name="authorization_groups"),
+                migrations.RemoveField(model_name="product", name="members"),
+                migrations.RemoveField(model_name="product", name="authorization_groups"),
+                # Flip dojo's eight authorization shells to managed=False.
+                # The class definitions stay in dojo/authorization/models.py
+                # so historical pro migration bases referencing them keep
+                # resolving when Django reloads project state.
+                migrations.AlterModelOptions(name="dojo_group", options={"managed": False}),
+                migrations.AlterModelOptions(name="dojo_group_member", options={"managed": False}),
+                migrations.AlterModelOptions(name="global_role", options={"managed": False}),
+                migrations.AlterModelOptions(name="product_group", options={"managed": False}),
+                migrations.AlterModelOptions(name="product_member", options={"managed": False}),
+                migrations.AlterModelOptions(name="product_type_group", options={"managed": False}),
+                migrations.AlterModelOptions(name="product_type_member", options={"managed": False}),
+                migrations.AlterModelOptions(name="role", options={"managed": False, "ordering": ("name",)}),
+                # Pin the db_table for each shell so subsequent state
+                # operations don't auto-generate a new table name.
+                migrations.AlterModelTable(name="dojo_group", table="dojo_dojo_group"),
+                migrations.AlterModelTable(name="dojo_group_member", table="dojo_dojo_group_member"),
+                migrations.AlterModelTable(name="global_role", table="dojo_global_role"),
+                migrations.AlterModelTable(name="product_group", table="dojo_product_group"),
+                migrations.AlterModelTable(name="product_member", table="dojo_product_member"),
+                migrations.AlterModelTable(name="product_type_group", table="dojo_product_type_group"),
+                migrations.AlterModelTable(name="product_type_member", table="dojo_product_type_member"),
+                migrations.AlterModelTable(name="role", table="dojo_role"),
+            ],
+            database_operations=[],
+        ),
+        # Drop the relocated default-group columns from System_Settings now
+        # that ``pro.0052`` has copied them onto ``EnhancedSystemSettings``.
+        migrations.RemoveField(
+            model_name="system_settings",
+            name="default_group",
+        ),
+        migrations.RemoveField(
+            model_name="system_settings",
+            name="default_group_role",
+        ),
+        migrations.RemoveField(
+            model_name="system_settings",
+            name="default_group_email_pattern",
+        ),
+    ]
diff --git a/dojo/development_environment/views.py b/dojo/development_environment/views.py
index 4a0b3b20dfb..8705fdd4c7c 100644
--- a/dojo/development_environment/views.py
+++ b/dojo/development_environment/views.py
@@ -9,7 +9,6 @@
 from django.urls import reverse
 
 from dojo.authorization.authorization import user_has_configuration_permission_or_403
-from dojo.authorization.authorization_decorators import user_is_configuration_authorized
 from dojo.filters import DevelopmentEnvironmentFilter
 from dojo.forms import Delete_Dev_EnvironmentForm, Development_EnvironmentForm
 from dojo.models import Development_Environment
@@ -35,7 +34,6 @@ def dev_env(request):
         "name_words": name_words})
 
 
-@user_is_configuration_authorized("dojo.add_development_environment")
 def add_dev_env(request):
     form = Development_EnvironmentForm()
     if request.method == "POST":
@@ -56,7 +54,6 @@ def add_dev_env(request):
     })
 
 
-@user_is_configuration_authorized("dojo.change_development_environment")
 def edit_dev_env(request, deid):
     de = get_object_or_404(Development_Environment, pk=deid)
     form1 = Development_EnvironmentForm(instance=de)
diff --git a/dojo/endpoint/queries.py b/dojo/endpoint/queries.py
index f8336b75f75..e2b43be1051 100644
--- a/dojo/endpoint/queries.py
+++ b/dojo/endpoint/queries.py
@@ -1,14 +1,11 @@
-from crum import get_current_user
-from django.db.models import Q, Subquery
+try:
+    from dojo.authorization.query_filters import get_auth_filter
+except ImportError:
+    def get_auth_filter(key): return None
 
-from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission
 from dojo.models import (
     Endpoint,
     Endpoint_Status,
-    Product_Group,
-    Product_Member,
-    Product_Type_Group,
-    Product_Type_Member,
 )
 from dojo.request_cache import cache_for_request
 
@@ -16,174 +13,30 @@
 # Cached: all parameters are hashable, no dynamic queryset filtering
 @cache_for_request
 def get_authorized_endpoints(permission, user=None):
-    """Cached - returns all endpoints the user is authorized to see."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Endpoint.objects.none()
-
-    endpoints = Endpoint.objects.all().order_by("id")
-
-    if user.is_superuser:
-        return endpoints
-
-    if user_has_global_permission(user, permission):
-        return endpoints
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return endpoints.filter(
-        Q(product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(product_id__in=Subquery(authorized_product_roles))
-        | Q(product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("endpoint.get_authorized_endpoints")
+    if impl:
+        return impl(permission, user=user)
+    return Endpoint.objects.all().order_by("id")
 
 
 def get_authorized_endpoints_for_queryset(permission, queryset, user=None):
-    """Filters a provided queryset for authorization. Not cached due to dynamic queryset parameter."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Endpoint.objects.none()
-
-    if user.is_superuser:
-        return queryset
-
-    if user_has_global_permission(user, permission):
-        return queryset
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return queryset.filter(
-        Q(product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(product_id__in=Subquery(authorized_product_roles))
-        | Q(product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("endpoint.get_authorized_endpoints_for_queryset")
+    if impl:
+        return impl(permission, queryset, user=user)
+    return queryset
 
 
 # Cached: all parameters are hashable, no dynamic queryset filtering
 @cache_for_request
 def get_authorized_endpoint_status(permission, user=None):
-    """Cached - returns all endpoint statuses the user is authorized to see."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Endpoint_Status.objects.none()
-
-    endpoint_status = Endpoint_Status.objects.all().order_by("id")
-
-    if user.is_superuser:
-        return endpoint_status
-
-    if user_has_global_permission(user, permission):
-        return endpoint_status
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return endpoint_status.filter(
-        Q(endpoint__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(endpoint__product_id__in=Subquery(authorized_product_roles))
-        | Q(endpoint__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(endpoint__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("endpoint.get_authorized_endpoint_status")
+    if impl:
+        return impl(permission, user=user)
+    return Endpoint_Status.objects.all().order_by("id")
 
 
 def get_authorized_endpoint_status_for_queryset(permission, queryset, user=None):
-    """Filters a provided queryset for authorization. Not cached due to dynamic queryset parameter."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Endpoint_Status.objects.none()
-
-    if user.is_superuser:
-        return queryset
-
-    if user_has_global_permission(user, permission):
-        return queryset
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return queryset.filter(
-        Q(endpoint__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(endpoint__product_id__in=Subquery(authorized_product_roles))
-        | Q(endpoint__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(endpoint__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("endpoint.get_authorized_endpoint_status_for_queryset")
+    if impl:
+        return impl(permission, queryset, user=user)
+    return queryset
diff --git a/dojo/endpoint/views.py b/dojo/endpoint/views.py
index fa57eeddc77..74c922f9ea7 100644
--- a/dojo/endpoint/views.py
+++ b/dojo/endpoint/views.py
@@ -16,8 +16,6 @@
 from django.utils import timezone
 
 from dojo.authorization.authorization import user_has_permission_or_403
-from dojo.authorization.authorization_decorators import user_is_authorized
-from dojo.authorization.roles_permissions import Permissions
 from dojo.celery_dispatch import dojo_dispatch_task
 from dojo.endpoint.queries import get_authorized_endpoints_for_queryset
 from dojo.endpoint.utils import clean_hosts_run, endpoint_meta_import
@@ -66,7 +64,7 @@ def process_endpoints_view(request, *, host_view=False, vulnerable=False):
     endpoints = endpoints.prefetch_related("product", "product__tags", "tags").annotate(
         active_finding_count=Coalesce(active_finding_subquery, Value(0)),
     ).distinct()
-    endpoints = get_authorized_endpoints_for_queryset(Permissions.Location_View, endpoints, request.user)
+    endpoints = get_authorized_endpoints_for_queryset("view", endpoints, request.user)
     filter_string_matching = get_system_setting("filter_string_matching", False)
     filter_class = EndpointFilterWithoutObjectLookups if filter_string_matching else EndpointFilter
     if host_view:
@@ -91,7 +89,7 @@ def process_endpoints_view(request, *, host_view=False, vulnerable=False):
         p = request.GET.getlist("product", [])
         if len(p) == 1:
             product = get_object_or_404(Product, id=p[0])
-            user_has_permission_or_403(request.user, product, Permissions.Product_View)
+            user_has_permission_or_403(request.user, product, "view")
             product_tab = Product_Tab(product, view_name, tab="endpoints")
 
     return render(
@@ -181,17 +179,14 @@ def process_endpoint_view(request, eid, *, host_view=False):
                    })
 
 
-@user_is_authorized(Endpoint, Permissions.Location_View, "eid")
 def view_endpoint(request, eid):
     return process_endpoint_view(request, eid, host_view=False)
 
 
-@user_is_authorized(Endpoint, Permissions.Location_View, "eid")
 def view_endpoint_host(request, eid):
     return process_endpoint_view(request, eid, host_view=True)
 
 
-@user_is_authorized(Endpoint, Permissions.Location_Edit, "eid")
 def edit_endpoint(request, eid):
     endpoint = get_object_or_404(Endpoint, id=eid)
 
@@ -219,7 +214,6 @@ def edit_endpoint(request, eid):
                    })
 
 
-@user_is_authorized(Endpoint, Permissions.Location_Delete, "eid")
 def delete_endpoint(request, eid):
     endpoint = get_object_or_404(Endpoint, pk=eid)
     product = endpoint.product
@@ -254,7 +248,6 @@ def delete_endpoint(request, eid):
                    })
 
 
-@user_is_authorized(Product, Permissions.Location_Add, "pid")
 def add_endpoint(request, pid):
     product = get_object_or_404(Product, id=pid)
     template = "dojo/add_endpoint.html"
@@ -287,7 +280,7 @@ def add_product_endpoint(request):
     if request.method == "POST":
         form = AddEndpointForm(request.POST)
         if form.is_valid():
-            user_has_permission_or_403(request.user, form.product, Permissions.Location_Add)
+            user_has_permission_or_403(request.user, form.product, "add")
             endpoints = form.save()
             tags = request.POST.get("tags")
             for e in endpoints:
@@ -306,7 +299,6 @@ def add_product_endpoint(request):
                    })
 
 
-@user_is_authorized(Endpoint, Permissions.Location_Edit, "eid")
 def manage_meta_data(request, eid):
     endpoint = Endpoint.objects.get(id=eid)
     meta_data_query = DojoMeta.objects.filter(endpoint=endpoint)
@@ -342,9 +334,9 @@ def endpoint_bulk_update_all(request, pid=None):
 
             if pid is not None:
                 product = get_object_or_404(Product, id=pid)
-                user_has_permission_or_403(request.user, product, Permissions.Location_Delete)
+                user_has_permission_or_403(request.user, product, "delete")
 
-            endpoints = get_authorized_endpoints_for_queryset(Permissions.Location_Delete, endpoints, request.user)
+            endpoints = get_authorized_endpoints_for_queryset("delete", endpoints, request.user)
 
             skipped_endpoint_count = total_endpoint_count - endpoints.count()
             deleted_endpoint_count = endpoints.count()
@@ -366,9 +358,9 @@ def endpoint_bulk_update_all(request, pid=None):
 
             if pid is not None:
                 product = get_object_or_404(Product, id=pid)
-                user_has_permission_or_403(request.user, product, Permissions.Finding_Edit)
+                user_has_permission_or_403(request.user, product, "edit")
 
-            endpoints = get_authorized_endpoints_for_queryset(Permissions.Location_Edit, endpoints, request.user)
+            endpoints = get_authorized_endpoints_for_queryset("edit", endpoints, request.user)
 
             skipped_endpoint_count = total_endpoint_count - endpoints.count()
             updated_endpoint_count = endpoints.count()
@@ -396,7 +388,6 @@ def endpoint_bulk_update_all(request, pid=None):
     return HttpResponseRedirect(reverse("endpoint", args=()))
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def endpoint_status_bulk_update(request, fid):
     if request.method == "POST":
         post = request.POST
@@ -471,7 +462,6 @@ def migrate_endpoints_view(request):
         })
 
 
-@user_is_authorized(Product, Permissions.Location_Edit, "pid")
 def import_endpoint_meta(request, pid):
     product = get_object_or_404(Product, id=pid)
     form = ImportEndpointMetaForm()
@@ -506,13 +496,11 @@ def import_endpoint_meta(request, pid):
     })
 
 
-@user_is_authorized(Endpoint, Permissions.Location_View, "eid")
 def endpoint_report(request, eid):
     endpoint = get_object_or_404(Endpoint, id=eid)
     return generate_report(request, endpoint, host_view=False)
 
 
-@user_is_authorized(Endpoint, Permissions.Location_View, "eid")
 def endpoint_host_report(request, eid):
     endpoint = get_object_or_404(Endpoint, id=eid)
     return generate_report(request, endpoint, host_view=True)
diff --git a/dojo/engagement/queries.py b/dojo/engagement/queries.py
index cd720eb4251..b46bd51aebf 100644
--- a/dojo/engagement/queries.py
+++ b/dojo/engagement/queries.py
@@ -1,48 +1,16 @@
-from crum import get_current_user
-from django.db.models import Q, Subquery
+try:
+    from dojo.authorization.query_filters import get_auth_filter
+except ImportError:
+    def get_auth_filter(key): return None
 
-from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission
-from dojo.models import Engagement, Product_Group, Product_Member, Product_Type_Group, Product_Type_Member
+from dojo.models import Engagement
 from dojo.request_cache import cache_for_request
 
 
 # Cached: all parameters are hashable, no dynamic queryset filtering
 @cache_for_request
 def get_authorized_engagements(permission):
-    user = get_current_user()
-
-    if user is None:
-        return Engagement.objects.none()
-
-    if user.is_superuser:
-        return Engagement.objects.all().order_by("id")
-
-    if user_has_global_permission(user, permission):
-        return Engagement.objects.all().order_by("id")
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return Engagement.objects.filter(
-        Q(product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(product_id__in=Subquery(authorized_product_roles))
-        | Q(product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(product_id__in=Subquery(authorized_product_groups)),
-    ).order_by("id")
+    impl = get_auth_filter("engagement.get_authorized_engagements")
+    if impl:
+        return impl(permission)
+    return Engagement.objects.all().order_by("id")
diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py
index b70f9b3ae88..0154aa5d336 100644
--- a/dojo/engagement/views.py
+++ b/dojo/engagement/views.py
@@ -34,8 +34,6 @@
 
 import dojo.risk_acceptance.helper as ra_helper
 from dojo.authorization.authorization import user_has_permission_or_403
-from dojo.authorization.authorization_decorators import user_is_authorized
-from dojo.authorization.roles_permissions import Permissions
 from dojo.celery_dispatch import dojo_dispatch_task
 from dojo.endpoint.utils import save_endpoints_to_add
 from dojo.engagement.queries import get_authorized_engagements
@@ -131,7 +129,7 @@ def engagement_calendar(request):
         raise Resolver404
 
     if "lead" not in request.GET or "0" in request.GET.getlist("lead"):
-        engagements = get_authorized_engagements(Permissions.Engagement_View)
+        engagements = get_authorized_engagements("view")
     else:
         filters = []
         leads = request.GET.getlist("lead", "")
@@ -139,7 +137,7 @@ def engagement_calendar(request):
             leads.remove("-1")
             filters.append(Q(lead__isnull=True))
         filters.append(Q(lead__in=leads))
-        engagements = get_authorized_engagements(Permissions.Engagement_View).filter(reduce(operator.or_, filters))
+        engagements = get_authorized_engagements("view").filter(reduce(operator.or_, filters))
 
     engagements = engagements.select_related("lead")
     engagements = engagements.prefetch_related("product")
@@ -154,7 +152,7 @@ def engagement_calendar(request):
             "caltype": "engagements",
             "leads": request.GET.getlist("lead", ""),
             "engagements": engagements,
-            "users": get_authorized_users(Permissions.Engagement_View),
+            "users": get_authorized_users("view"),
         })
 
 
@@ -163,7 +161,7 @@ def get_filtered_engagements(request, view):
         msg = f"View {view} is not allowed"
         raise ValidationError(msg)
 
-    engagements = get_authorized_engagements(Permissions.Engagement_View).order_by("-target_start")
+    engagements = get_authorized_engagements("view").order_by("-target_start")
 
     if view == "active":
         engagements = engagements.filter(active=True)
@@ -197,8 +195,8 @@ def engagements(request, view):
     filtered_engagements = get_filtered_engagements(request, view)
 
     engs = get_page_items(request, filtered_engagements.qs, 25)
-    product_name_words = sorted(get_authorized_products(Permissions.Product_View).values_list("name", flat=True))
-    engagement_name_words = sorted(get_authorized_engagements(Permissions.Engagement_View).values_list("name", flat=True).distinct())
+    product_name_words = sorted(get_authorized_products("view").values_list("name", flat=True))
+    engagement_name_words = sorted(get_authorized_engagements("view").values_list("name", flat=True).distinct())
 
     add_breadcrumb(
         title=f"{view.capitalize()} Engagements",
@@ -217,7 +215,7 @@ def engagements(request, view):
 
 def engagements_all(request):
 
-    products_with_engagements = get_authorized_products(Permissions.Engagement_View)
+    products_with_engagements = get_authorized_products("view")
     products_with_engagements = products_with_engagements.filter(~Q(engagement=None)).distinct()
 
     # count using prefetch instead of just using 'engagement__set_test_test` to avoid loading all test in memory just to count them
@@ -252,7 +250,7 @@ def engagements_all(request):
     prods = get_page_items(request, filtered.qs, 25)
     prods.paginator.count = sum(len(prod.engagement_set.all()) for prod in prods)
     name_words = products_with_engagements.values_list("name", flat=True)
-    eng_words = get_authorized_engagements(Permissions.Engagement_View).values_list("name", flat=True).distinct()
+    eng_words = get_authorized_engagements("view").values_list("name", flat=True).distinct()
 
     add_breadcrumb(
         title="All Engagements",
@@ -269,7 +267,6 @@ def engagements_all(request):
         })
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 def edit_engagement(request, eid):
     engagement = Engagement.objects.get(pk=eid)
     is_ci_cd = engagement.engagement_type == "CI/CD"
@@ -288,7 +285,7 @@ def edit_engagement(request, eid):
                 user_has_permission_or_403(
                     request.user,
                     form.cleaned_data.get("product"),
-                    Permissions.Engagement_Edit,
+                    "edit",
                 )
             engagement.product = form.cleaned_data.get("product")
             engagement = form.save(commit=False)
@@ -345,7 +342,6 @@ def edit_engagement(request, eid):
     })
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Delete, "eid")
 def delete_engagement(request, eid):
     engagement = get_object_or_404(Engagement, pk=eid)
     product = engagement.product
@@ -387,7 +383,6 @@ def delete_engagement(request, eid):
     })
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 def copy_engagement(request, eid):
     engagement = get_object_or_404(Engagement, id=eid)
     product = engagement.product
@@ -454,7 +449,7 @@ def get_filtered_tests(
     def get(self, request, eid, *args, **kwargs):
         eng = get_object_or_404(Engagement, id=eid)
         # Make sure the user is authorized
-        user_has_permission_or_403(request.user, eng, Permissions.Engagement_View)
+        user_has_permission_or_403(request.user, eng, "view")
         tests = eng.test_set.all().order_by("test_type__name", "-updated")
         default_page_num = 10
         tests_filter = self.get_filtered_tests(request, tests, eng)
@@ -513,7 +508,7 @@ def get(self, request, eid, *args, **kwargs):
     def post(self, request, eid, *args, **kwargs):
         eng = get_object_or_404(Engagement, id=eid)
         # Make sure the user is authorized
-        user_has_permission_or_403(request.user, eng, Permissions.Engagement_View)
+        user_has_permission_or_403(request.user, eng, "view")
         tests = eng.test_set.all().order_by("test_type__name", "-updated")
         default_page_num = 10
 
@@ -544,7 +539,7 @@ def post(self, request, eid, *args, **kwargs):
             available_note_types = find_available_notetypes(notes)
         form = DoneForm()
         files = eng.files.all()
-        user_has_permission_or_403(request.user, eng, Permissions.Note_Add)
+        user_has_permission_or_403(request.user, eng, "add")
         eng.progress = "check_list"
         eng.save()
 
@@ -619,7 +614,6 @@ def prefetch_for_view_tests(tests):
     )
 
 
-@user_is_authorized(Engagement, Permissions.Test_Add, "eid")
 def add_tests(request, eid):
     eng = Engagement.objects.get(id=eid)
 
@@ -720,7 +714,7 @@ def get_engagement_or_product(
             msg = "Either Engagement or Product has to be provided"
             raise Exception(msg)
         # Ensure the supplied user has access to import to the engagement or product
-        user_has_permission_or_403(user, engagement_or_product, Permissions.Import_Scan_Result)
+        user_has_permission_or_403(user, engagement_or_product, "import")
 
         return engagement, product, engagement_or_product
 
@@ -1094,7 +1088,6 @@ def post(
         return self.success_redirect(request, context)
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 def close_eng(request, eid):
     eng = Engagement.objects.get(id=eid)
     close_engagement(eng)
@@ -1106,7 +1099,6 @@ def close_eng(request, eid):
     return HttpResponseRedirect(reverse("view_engagements", args=(eng.product.id, )))
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 @require_POST
 def unlink_jira(request, eid):
     eng = get_object_or_404(Engagement, id=eid)
@@ -1140,7 +1132,6 @@ def unlink_jira(request, eid):
         return HttpResponse(status=400)
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 def reopen_eng(request, eid):
     eng = Engagement.objects.get(id=eid)
     reopen_engagement(eng)
@@ -1159,7 +1150,6 @@ def reopen_eng(request, eid):
 """
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 def complete_checklist(request, eid):
     eng = get_object_or_404(Engagement, id=eid)
     try:
@@ -1209,7 +1199,6 @@ def complete_checklist(request, eid):
     })
 
 
-@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid")
 def add_risk_acceptance(request, eid, fid=None):
     eng = get_object_or_404(Engagement, id=eid)
     finding = None
@@ -1282,12 +1271,10 @@ def add_risk_acceptance(request, eid, fid=None):
                   })
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_View, "eid")
 def view_risk_acceptance(request, eid, raid):
     return view_edit_risk_acceptance(request, eid=eid, raid=raid, edit_mode=False)
 
 
-@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid")
 def edit_risk_acceptance(request, eid, raid):
     return view_edit_risk_acceptance(request, eid=eid, raid=raid, edit_mode=True)
 
@@ -1453,7 +1440,6 @@ def view_edit_risk_acceptance(request, eid, raid, *, edit_mode=False):
         })
 
 
-@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid")
 def expire_risk_acceptance(request, eid, raid):
     risk_acceptance = get_object_or_404(prefetch_for_expiration(Risk_Acceptance.objects.all()), pk=raid)
     # Validate the engagement ID exists before moving forward
@@ -1464,7 +1450,6 @@ def expire_risk_acceptance(request, eid, raid):
     return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(eid, raid)))
 
 
-@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid")
 def reinstate_risk_acceptance(request, eid, raid):
     risk_acceptance = get_object_or_404(prefetch_for_expiration(Risk_Acceptance.objects.all()), pk=raid)
     eng = get_object_or_404(Engagement.objects.filter(risk_acceptance=risk_acceptance), pk=eid)
@@ -1476,7 +1461,6 @@ def reinstate_risk_acceptance(request, eid, raid):
     return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(eid, raid)))
 
 
-@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid")
 def delete_risk_acceptance(request, eid, raid):
     risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid)
     eng = get_object_or_404(Engagement.objects.filter(risk_acceptance=risk_acceptance), pk=eid)
@@ -1490,7 +1474,6 @@ def delete_risk_acceptance(request, eid, raid):
     return HttpResponseRedirect(reverse("view_engagement", args=(eng.id, )))
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_View, "eid")
 def download_risk_acceptance(request, eid, raid):
     mimetypes.init()
     risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid)
@@ -1515,7 +1498,6 @@ def download_risk_acceptance(request, eid, raid):
 """
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")
 def upload_threatmodel(request, eid):
     eng = Engagement.objects.get(id=eid)
     add_breadcrumb(
@@ -1548,13 +1530,11 @@ def upload_threatmodel(request, eid):
     })
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_View, "eid")
 def view_threatmodel(request, eid):
     eng = get_object_or_404(Engagement, pk=eid)
     return generate_file_response_from_file_path(eng.tmodel_path)
 
 
-@user_is_authorized(Engagement, Permissions.Engagement_View, "eid")
 def engagement_ics(request, eid):
     eng = get_object_or_404(Engagement, id=eid)
     start_date = datetime.combine(eng.target_start, datetime.min.time())
diff --git a/dojo/filters.py b/dojo/filters.py
index a586c35540d..96184d92e2d 100644
--- a/dojo/filters.py
+++ b/dojo/filters.py
@@ -36,7 +36,6 @@
 
 # from tagulous.forms import TagWidget
 # import tagulous
-from dojo.authorization.roles_permissions import Permissions
 from dojo.endpoint.queries import get_authorized_endpoints_for_queryset
 from dojo.engagement.queries import get_authorized_engagements
 from dojo.finding.helper import (
@@ -63,7 +62,6 @@
     App_Analysis,
     ChoiceQuestion,
     Development_Environment,
-    Dojo_Group,
     Dojo_User,
     DojoMeta,
     Endpoint,
@@ -1058,9 +1056,9 @@ class ComponentFilter(ProductComponentFilter):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.form.fields[
-            "test__engagement__product__prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
+            "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view")
         self.form.fields[
-            "test__engagement__product"].queryset = get_authorized_products(Permissions.Product_View)
+            "test__engagement__product"].queryset = get_authorized_products("view")
 
 
 class EngagementDirectFilterHelper(FilterSet):
@@ -1114,8 +1112,8 @@ class EngagementDirectFilter(EngagementDirectFilterHelper, DojoFilter):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.form.fields["product__prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
-        self.form.fields["lead"].queryset = get_authorized_users(Permissions.Product_Type_View) \
+        self.form.fields["product__prod_type"].queryset = get_authorized_product_types("view")
+        self.form.fields["lead"].queryset = get_authorized_users("view") \
             .filter(engagement__lead__isnull=False).distinct()
 
     class Meta:
@@ -1197,8 +1195,8 @@ class EngagementFilter(EngagementFilterHelper, DojoFilter):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.form.fields["prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
-        self.form.fields["engagement__lead"].queryset = get_authorized_users(Permissions.Product_Type_View) \
+        self.form.fields["prod_type"].queryset = get_authorized_product_types("view")
+        self.form.fields["engagement__lead"].queryset = get_authorized_users("view") \
             .filter(engagement__lead__isnull=False).distinct()
         self.form.fields["tags"].help_text = labels.ASSET_FILTERS_TAGS_HELP
         self.form.fields["not_tags"].help_text = labels.ASSET_FILTERS_NOT_TAGS_HELP
@@ -1218,7 +1216,7 @@ class ProductEngagementsFilter(DojoFilter):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.form.fields["engagement__lead"].queryset = get_authorized_users(Permissions.Product_Type_View) \
+        self.form.fields["engagement__lead"].queryset = get_authorized_users("view") \
             .filter(engagement__lead__isnull=False).distinct()
 
     class Meta:
@@ -1305,7 +1303,7 @@ class ProductEngagementFilter(ProductEngagementFilterHelper, DojoFilter):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.form.fields["lead"].queryset = get_authorized_users(
-            Permissions.Product_Type_View).filter(engagement__lead__isnull=False).distinct()
+            "view").filter(engagement__lead__isnull=False).distinct()
 
 
 class ProductEngagementFilterWithoutObjectLookups(ProductEngagementFilterHelper, DojoFilter):
@@ -1464,7 +1462,7 @@ def __init__(self, *args, **kwargs):
         if "user" in kwargs:
             self.user = kwargs.pop("user")
         super().__init__(*args, **kwargs)
-        self.form.fields["prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
+        self.form.fields["prod_type"].queryset = get_authorized_product_types("view")
         self.form.fields["tags"].help_text = labels.ASSET_FILTERS_TAGS_HELP
         self.form.fields["not_tags"].help_text = labels.ASSET_FILTERS_NOT_TAGS_HELP
 
@@ -2276,7 +2274,7 @@ def set_related_object_fields(self, *args: list, **kwargs: dict):
             # Filter tests by engagement - get_authorized_tests doesn't support engagement param
             engagement = Engagement.objects.filter(id=self.eid).select_related("product").first()
             if engagement:
-                self.form.fields["test"].queryset = get_authorized_tests(Permissions.Test_View, product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type")
+                self.form.fields["test"].queryset = get_authorized_tests("view", product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type")
         elif self.pid is not None:
             # Product context: filter finding groups by product
             if "test__engagement__product" in self.form.fields:
@@ -2289,20 +2287,20 @@ def set_related_object_fields(self, *args: list, **kwargs: dict):
                     product_id=self.pid,
                 ).all()
             if "test" in self.form.fields:
-                self.form.fields["test"].queryset = get_authorized_tests(Permissions.Test_View, product=self.pid).prefetch_related("test_type")
+                self.form.fields["test"].queryset = get_authorized_tests("view", product=self.pid).prefetch_related("test_type")
         else:
             # Global context: show all authorized finding groups
             self.form.fields[
-                "test__engagement__product__prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
-            self.form.fields["test__engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
+                "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view")
+            self.form.fields["test__engagement"].queryset = get_authorized_engagements("view")
             if "test" in self.form.fields:
                 del self.form.fields["test"]
 
         if self.form.fields.get("test__engagement__product"):
-            self.form.fields["test__engagement__product"].queryset = get_authorized_products(Permissions.Product_View)
+            self.form.fields["test__engagement__product"].queryset = get_authorized_products("view")
         if self.form.fields.get("finding_group", None):
-            self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset(Permissions.Finding_Group_View, finding_group_query, user=self.user)
-        self.form.fields["reporter"].queryset = get_authorized_users(Permissions.Finding_View)
+            self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset("view", finding_group_query, user=self.user)
+        self.form.fields["reporter"].queryset = get_authorized_users("view")
         self.form.fields["reviewers"].queryset = self.form.fields["reporter"].queryset
 
 
@@ -2336,8 +2334,8 @@ def set_related_object_fields(self):
             if "product" in self.form.fields:
                 del self.form.fields["product"]
         else:
-            self.form.fields["product"].queryset = get_authorized_products(Permissions.Product_View)
-            self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
+            self.form.fields["product"].queryset = get_authorized_products("view")
+            self.form.fields["engagement"].queryset = get_authorized_engagements("view")
 
 
 class AcceptedFindingFilter(FindingFilter):
@@ -2351,8 +2349,8 @@ class AcceptedFindingFilter(FindingFilter):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.form.fields["risk_acceptance__owner"].queryset = get_authorized_users(Permissions.Finding_View)
-        self.form.fields["risk_acceptance"].queryset = get_authorized_risk_acceptances(Permissions.Risk_Acceptance)
+        self.form.fields["risk_acceptance__owner"].queryset = get_authorized_users("view")
+        self.form.fields["risk_acceptance"].queryset = get_authorized_risk_acceptances("edit")
 
 
 class AcceptedFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups):
@@ -2408,7 +2406,7 @@ def set_hash_codes(self, *args: list, **kwargs: dict):
 
     def filter_queryset(self, *args: list, **kwargs: dict):
         queryset = super().filter_queryset(*args, **kwargs)
-        queryset = get_authorized_findings_for_queryset(Permissions.Finding_View, queryset, self.user)
+        queryset = get_authorized_findings_for_queryset("view", queryset, self.user)
         return queryset.exclude(pk=self.finding.pk)
 
 
@@ -2701,11 +2699,11 @@ def __init__(self, *args, **kwargs):
                 product_id=self.pid,
             ).all()
         else:
-            self.form.fields["finding__test__engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View).order_by("name")
+            self.form.fields["finding__test__engagement"].queryset = get_authorized_engagements("view").order_by("name")
 
         if "finding__test__engagement__product__prod_type" in self.form.fields:
             self.form.fields[
-                "finding__test__engagement__product__prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
+                "finding__test__engagement__product__prod_type"].queryset = get_authorized_product_types("view")
 
     class Meta:
         model = Endpoint_Status
@@ -2953,12 +2951,12 @@ def __init__(self, *args, **kwargs):
         if "user" in kwargs:
             self.user = kwargs.pop("user")
         super().__init__(*args, **kwargs)
-        self.form.fields["product"].queryset = get_authorized_products(Permissions.Product_View)
+        self.form.fields["product"].queryset = get_authorized_products("view")
 
     @property
     def qs(self):
         parent = super().qs
-        return get_authorized_endpoints_for_queryset(Permissions.Location_View, parent)
+        return get_authorized_endpoints_for_queryset("view", parent)
 
     class Meta:
         model = Endpoint
@@ -3099,7 +3097,7 @@ def __init__(self, *args, **kwargs):
     @property
     def qs(self):
         parent = super().qs
-        return get_authorized_endpoints_for_queryset(Permissions.Location_View, parent)
+        return get_authorized_endpoints_for_queryset("view", parent)
 
     class Meta:
         model = Endpoint
@@ -3226,7 +3224,7 @@ def __init__(self, *args, **kwargs):
         super(DojoFilter, self).__init__(*args, **kwargs)
         self.form.fields["test_type"].queryset = Test_Type.objects.filter(test__engagement=self.engagement).distinct().order_by("name")
         self.form.fields["api_scan_configuration"].queryset = Product_API_Scan_Configuration.objects.filter(product=self.engagement.product).distinct()
-        self.form.fields["lead"].queryset = get_authorized_users(Permissions.Product_Type_View) \
+        self.form.fields["lead"].queryset = get_authorized_users("view") \
             .filter(test__lead__isnull=False).distinct()
 
 
@@ -3468,7 +3466,7 @@ def manage_kwargs(self, kwargs):
     @property
     def qs(self):
         parent = super().qs
-        return get_authorized_findings_for_queryset(Permissions.Finding_View, parent)
+        return get_authorized_findings_for_queryset("view", parent)
 
 
 class ReportFindingFilter(ReportFindingFilterHelper, FindingTagFilter):
@@ -3488,7 +3486,7 @@ def __init__(self, *args, **kwargs):
         # duplicate_finding queryset needs to restricted in line with permissions
         # and inline with report scope to avoid a dropdown with 100K entries
         duplicate_finding_query_set = self.form.fields["duplicate_finding"].queryset
-        duplicate_finding_query_set = get_authorized_findings_for_queryset(Permissions.Finding_View, duplicate_finding_query_set)
+        duplicate_finding_query_set = get_authorized_findings_for_queryset("view", duplicate_finding_query_set)
 
         if self.test:
             duplicate_finding_query_set = duplicate_finding_query_set.filter(test=self.test)
@@ -3511,12 +3509,12 @@ def __init__(self, *args, **kwargs):
 
         if "test__engagement__product__prod_type" in self.form.fields:
             self.form.fields[
-                "test__engagement__product__prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
+                "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view")
         if "test__engagement__product" in self.form.fields:
             self.form.fields[
-                "test__engagement__product"].queryset = get_authorized_products(Permissions.Product_View)
+                "test__engagement__product"].queryset = get_authorized_products("view")
         if "test__engagement" in self.form.fields:
-            self.form.fields["test__engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
+            self.form.fields["test__engagement"].queryset = get_authorized_engagements("view")
 
 
 class ReportFindingFilterWithoutObjectLookups(ReportFindingFilterHelper, FindingTagStringFilter):
@@ -3690,6 +3688,7 @@ class UserFilter(DojoFilter):
             ("email", "email"),
             ("is_active", "is_active"),
             ("is_superuser", "is_superuser"),
+            ("is_staff", "is_staff"),
             ("date_joined", "date_joined"),
             ("last_login", "last_login"),
         ),
@@ -3697,22 +3696,13 @@ class UserFilter(DojoFilter):
             "username": "User Name",
             "is_active": "Active",
             "is_superuser": "Superuser",
+            "is_staff": "Staff",
         },
     )
 
     class Meta:
         model = Dojo_User
-        fields = ["is_superuser", "is_active", "first_name", "last_name", "username", "email"]
-
-
-class GroupFilter(DojoFilter):
-    name = CharFilter(lookup_expr="icontains")
-    description = CharFilter(lookup_expr="icontains")
-
-    class Meta:
-        model = Dojo_Group
-        fields = ["name", "description"]
-        exclude = ["users"]
+        fields = ["is_superuser", "is_staff", "is_active", "first_name", "last_name", "username", "email"]
 
 
 # This class is used exclusively by Findings
diff --git a/dojo/finding/queries.py b/dojo/finding/queries.py
index 3f074eabebc..06711882c42 100644
--- a/dojo/finding/queries.py
+++ b/dojo/finding/queries.py
@@ -1,22 +1,21 @@
 import logging
 
-from crum import get_current_user
 from django.conf import settings
-from django.db.models import Case, CharField, Count, Exists, F, Q, Subquery, Value, When
+from django.db.models import Case, CharField, Count, Exists, F, Q, Value, When
 from django.db.models.functions import Concat
 from django.db.models.query import Prefetch, QuerySet
 
-from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission
+try:
+    from dojo.authorization.query_filters import get_auth_filter
+except ImportError:
+    def get_auth_filter(key): return None
+
 from dojo.location.models import LocationFindingReference
 from dojo.location.status import FindingLocationStatus
 from dojo.models import (
     IMPORT_UNTOUCHED_FINDING,
     Endpoint_Status,
     Finding,
-    Product_Group,
-    Product_Member,
-    Product_Type_Group,
-    Product_Type_Member,
     Test_Import_Finding_Action,
     Vulnerability_Id,
 )
@@ -28,175 +27,33 @@
 # Cached: all parameters are hashable, no dynamic queryset filtering
 @cache_for_request
 def get_authorized_findings(permission, user=None):
-    """Cached - returns all findings the user is authorized to see."""
-    if user is None:
-        user = get_current_user()
-    if user is None:
-        return Finding.objects.none()
-    findings = Finding.objects.all().order_by("id")
-
-    if user.is_superuser:
-        return findings
-
-    if user_has_global_permission(user, permission):
-        return findings
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return findings.filter(
-        Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_roles))
-        | Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("finding.get_authorized_findings")
+    if impl:
+        return impl(permission, user=user)
+    return Finding.objects.all().order_by("id")
 
 
 def get_authorized_findings_for_queryset(permission, queryset, user=None):
-    """Filters a provided queryset for authorization. Not cached due to dynamic queryset parameter."""
-    if user is None:
-        user = get_current_user()
-    if user is None:
-        return Finding.objects.none()
-    findings = Finding.objects.all().order_by("id") if queryset is None else queryset
-
-    if user.is_superuser:
-        return findings
-
-    if user_has_global_permission(user, permission):
-        return findings
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return findings.filter(
-        Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_roles))
-        | Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("finding.get_authorized_findings_for_queryset")
+    if impl:
+        return impl(permission, queryset, user=user)
+    return Finding.objects.all().order_by("id") if queryset is None else queryset
 
 
 # Cached: all parameters are hashable, no dynamic queryset filtering
 @cache_for_request
 def get_authorized_vulnerability_ids(permission, user=None):
-    """Cached - returns all vulnerability IDs the user is authorized to see."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Vulnerability_Id.objects.none()
-
-    vulnerability_ids = Vulnerability_Id.objects.all()
-
-    if user.is_superuser:
-        return vulnerability_ids
-
-    if user_has_global_permission(user, permission):
-        return vulnerability_ids
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return vulnerability_ids.filter(
-        Q(finding__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(finding__test__engagement__product_id__in=Subquery(authorized_product_roles))
-        | Q(finding__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(finding__test__engagement__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("finding.get_authorized_vulnerability_ids")
+    if impl:
+        return impl(permission, user=user)
+    return Vulnerability_Id.objects.all()
 
 
 def get_authorized_vulnerability_ids_for_queryset(permission, queryset, user=None):
-    """Filters a provided queryset for authorization. Not cached due to dynamic queryset parameter."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Vulnerability_Id.objects.none()
-
-    if user.is_superuser:
-        return queryset
-
-    if user_has_global_permission(user, permission):
-        return queryset
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return queryset.filter(
-        Q(finding__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(finding__test__engagement__product_id__in=Subquery(authorized_product_roles))
-        | Q(finding__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(finding__test__engagement__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("finding.get_authorized_vulnerability_ids_for_queryset")
+    if impl:
+        return impl(permission, queryset, user=user)
+    return queryset
 
 
 def prefetch_for_findings(findings, prefetch_type="all", *, exclude_untouched=True):
@@ -254,8 +111,6 @@ def prefetch_for_findings(findings, prefetch_type="all", *, exclude_untouched=Tr
             "status_finding",
             "finding_group_set",
             "finding_group_set__jira_issue",  # Include both variants
-            "test__engagement__product__members",
-            "test__engagement__product__prod_type__members",
             "vulnerability_id_set",
         )
         base_status = LocationFindingReference.objects.prefetch_related("location__url").all()
@@ -292,8 +147,6 @@ def prefetch_for_findings(findings, prefetch_type="all", *, exclude_untouched=Tr
             "status_finding",
             "finding_group_set",
             "finding_group_set__jira_issue",  # Include both variants
-            "test__engagement__product__members",
-            "test__engagement__product__prod_type__members",
             "vulnerability_id_set",
         )
         base_status = Endpoint_Status.objects.prefetch_related("endpoint")
diff --git a/dojo/finding/views.py b/dojo/finding/views.py
index a557c4a97aa..0d32235bf1f 100644
--- a/dojo/finding/views.py
+++ b/dojo/finding/views.py
@@ -31,11 +31,6 @@
 import dojo.finding.helper as finding_helper
 import dojo.risk_acceptance.helper as ra_helper
 from dojo.authorization.authorization import user_has_global_permission_or_403, user_has_permission_or_403
-from dojo.authorization.authorization_decorators import (
-    user_has_global_permission,
-    user_is_authorized,
-)
-from dojo.authorization.roles_permissions import Permissions
 from dojo.celery_dispatch import dojo_dispatch_task
 from dojo.filters import (
     AcceptedFindingFilter,
@@ -278,7 +273,7 @@ def filter_findings_by_form(self, request: HttpRequest, findings: QuerySet[Findi
         )
 
     def get_filtered_findings(self):
-        findings = get_authorized_findings(Permissions.Finding_View)
+        findings = get_authorized_findings("view")
         # Annotate computed SLA age in days: sla_expiration_date - (sla_start_date or date)
         # Handle NULL sla_expiration_date by using Coalesce to provide a large default value
         # so NULLs sort last when sorting ascending (most urgent first)
@@ -319,7 +314,7 @@ def get_initial_context(self, request: HttpRequest):
         # Look to see if the product was used
         if product_id := self.get_product_id():
             product = get_object_or_404(Product, id=product_id)
-            user_has_permission_or_403(request.user, product, Permissions.Product_View)
+            user_has_permission_or_403(request.user, product, "view")
             context["show_product_column"] = False
             context["product_tab"] = Product_Tab(product, title="Findings", tab="findings")
             context["jira_project"] = jira_services.get_project(product)
@@ -327,7 +322,7 @@ def get_initial_context(self, request: HttpRequest):
                 context["github_config"] = github_config.git_conf_id
         elif engagement_id := self.get_engagement_id():
             engagement = get_object_or_404(Engagement, id=engagement_id)
-            user_has_permission_or_403(request.user, engagement, Permissions.Engagement_View)
+            user_has_permission_or_403(request.user, engagement, "view")
             context["show_product_column"] = False
             context["product_tab"] = Product_Tab(engagement.product, title=engagement.name, tab="engagements")
             context["jira_project"] = jira_services.get_project(engagement)
@@ -527,7 +522,7 @@ def get_similar_findings(self, request: HttpRequest, finding: Finding):
         finding_filter_class = SimilarFindingFilterWithoutObjectLookups if filter_string_matching else SimilarFindingFilter
         similar_findings_filter = finding_filter_class(
             request.GET,
-            queryset=get_authorized_findings(Permissions.Finding_View)
+            queryset=get_authorized_findings("view")
             .filter(test__engagement__product=finding.test.engagement.product)
             .exclude(id=finding.id),
             user=request.user,
@@ -669,7 +664,7 @@ def get(self, request: HttpRequest, finding_id: int):
         finding = self.get_finding(finding_id)
         user = self.get_dojo_user(request)
         # Make sure the user is authorized
-        user_has_permission_or_403(user, finding, Permissions.Finding_View)
+        user_has_permission_or_403(user, finding, "view")
         # Set up the initial context
         context = self.get_initial_context(request, finding, user)
         # Add in the other extras
@@ -687,9 +682,9 @@ def post(self, request: HttpRequest, finding_id):
         finding = self.get_finding(finding_id)
         user = self.get_dojo_user(request)
         # Make sure the user is authorized
-        user_has_permission_or_403(user, finding, Permissions.Finding_View)
+        user_has_permission_or_403(user, finding, "view")
         # Quick perms check to determine if the user has access to add a note to the finding
-        user_has_permission_or_403(user, finding, Permissions.Note_Add)
+        user_has_permission_or_403(user, finding, "add")
         # Set up the initial context
         context = self.get_initial_context(request, finding, user)
         # Determine the validity of the form
@@ -1049,7 +1044,7 @@ def get(self, request: HttpRequest, finding_id: int):
         # Get the initial objects
         finding = self.get_finding(finding_id)
         # Make sure the user is authorized
-        user_has_permission_or_403(request.user, finding, Permissions.Finding_Edit)
+        user_has_permission_or_403(request.user, finding, "edit")
         # Set up the initial context
         context = self.get_initial_context(request, finding)
         # Render the form
@@ -1059,7 +1054,7 @@ def post(self, request: HttpRequest, finding_id: int):
         # Get the initial objects
         finding = self.get_finding(finding_id)
         # Make sure the user is authorized
-        user_has_permission_or_403(request.user, finding, Permissions.Finding_Edit)
+        user_has_permission_or_403(request.user, finding, "edit")
         # Set up the initial context
         context = self.get_initial_context(request, finding)
         # Process the form
@@ -1120,7 +1115,7 @@ def post(self, request: HttpRequest, finding_id):
         # Get the initial objects
         finding = self.get_finding(finding_id)
         # Make sure the user is authorized
-        user_has_permission_or_403(request.user, finding, Permissions.Finding_Delete)
+        user_has_permission_or_403(request.user, finding, "delete")
         # Get the finding form
         context = {
             "form": DeleteFindingForm(request.POST, instance=finding),
@@ -1133,7 +1128,6 @@ def post(self, request: HttpRequest, finding_id):
         raise PermissionDenied
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def close_finding(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     # in order to close a finding, we need to capture why it was closed
@@ -1203,7 +1197,6 @@ def close_finding(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def verify_finding(request, fid):
     finding = get_object_or_404(Finding, id=fid)
 
@@ -1262,7 +1255,6 @@ def verify_finding(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def defect_finding_review(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     # in order to close a finding, we need to capture why it was closed
@@ -1348,11 +1340,6 @@ def defect_finding_review(request, fid):
     )
 
 
-@user_is_authorized(
-    Finding,
-    Permissions.Finding_Edit,
-    "fid",
-)
 def reopen_finding(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     finding.active = True
@@ -1399,11 +1386,10 @@ def reopen_finding(request, fid):
     return HttpResponseRedirect(reverse("view_finding", args=(finding.id,)))
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def copy_finding(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     product = finding.test.engagement.product
-    tests = get_authorized_tests(Permissions.Test_Edit).filter(
+    tests = get_authorized_tests("edit").filter(
         engagement=finding.test.engagement,
     )
     form = CopyFindingForm(tests=tests)
@@ -1456,7 +1442,6 @@ def copy_finding(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def remediation_date(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     user = get_object_or_404(Dojo_User, id=request.user.id)
@@ -1493,7 +1478,6 @@ def remediation_date(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def touch_finding(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     finding.last_reviewed = timezone.now()
@@ -1504,7 +1488,6 @@ def touch_finding(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Risk_Acceptance, "fid")
 def simple_risk_accept(request, fid):
     finding = get_object_or_404(Finding, id=fid)
 
@@ -1522,7 +1505,6 @@ def simple_risk_accept(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Risk_Acceptance, "fid")
 def risk_unaccept(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     ra_helper.risk_unaccept(request.user, finding)
@@ -1539,7 +1521,6 @@ def risk_unaccept(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_View, "fid")
 def request_finding_review(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     user = get_object_or_404(Dojo_User, id=request.user.id)
@@ -1629,7 +1610,6 @@ def request_finding_review(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def clear_finding_review(request, fid):
     finding = get_object_or_404(Finding, id=fid)
     user = get_object_or_404(Dojo_User, id=request.user.id)
@@ -1709,8 +1689,8 @@ def clear_finding_review(request, fid):
     )
 
 
-@user_has_global_permission(Permissions.Finding_Add)
 def mktemplate(request, fid):
+    user_has_global_permission_or_403(request.user, "add")
     finding = get_object_or_404(Finding, id=fid)
     templates = Finding_Template.objects.filter(title=finding.title)
     if len(templates) > 0:
@@ -1769,11 +1749,10 @@ def mktemplate(request, fid):
     return HttpResponseRedirect(reverse("view_finding", args=(finding.id,)))
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def find_template_to_apply(request, fid):
     # Templates may contain sensitive data from any product; require global permission
     # to match the authorization level of the /template list view
-    user_has_global_permission_or_403(request.user, Permissions.Finding_Edit)
+    user_has_global_permission_or_403(request.user, "edit")
     finding = get_object_or_404(Finding, id=fid)
     test = get_object_or_404(Test, id=finding.test.id)
     templates_by_cve = (
@@ -1840,9 +1819,9 @@ def find_template_to_apply(request, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def choose_finding_template_options(request, tid, fid):
     finding = get_object_or_404(Finding, id=fid)
+    user_has_permission_or_403(request.user, finding, "edit")
     template = get_object_or_404(Finding_Template, id=tid)
     data = finding.__dict__.copy()
     # Remove tags and other non-serializable fields
@@ -1928,9 +1907,9 @@ def choose_finding_template_options(request, tid, fid):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 def apply_template_to_finding(request, fid, tid):
     finding = get_object_or_404(Finding, id=fid)
+    user_has_permission_or_403(request.user, finding, "edit")
     template = get_object_or_404(Finding_Template, id=tid)
 
     if request.method == "POST":
@@ -1994,7 +1973,6 @@ def apply_template_to_finding(request, fid, tid):
     return HttpResponseRedirect(reverse("view_finding", args=(finding.id,)))
 
 
-@user_has_global_permission(Permissions.Finding_Edit)
 def templates(request):
     templates = Finding_Template.objects.all().order_by("cwe")
     templates = TemplateFindingFilter(request.GET, queryset=templates)
@@ -2014,7 +1992,6 @@ def templates(request):
     )
 
 
-@user_has_global_permission(Permissions.Finding_Edit)
 def export_templates_to_json(request):
     leads_as_json = serializers.serialize("json", Finding_Template.objects.all())
     return HttpResponse(leads_as_json, content_type="application/json")
@@ -2108,7 +2085,6 @@ def apply_cwe_mitigation(apply_to_findings, template, *, update=True):
     return count
 
 
-@user_has_global_permission(Permissions.Finding_Add)
 def add_template(request):
     form = FindingTemplateForm()
     if request.method == "POST":
@@ -2150,7 +2126,6 @@ def add_template(request):
     )
 
 
-@user_has_global_permission(Permissions.Finding_Edit)
 def edit_template(request, tid):
     template = get_object_or_404(Finding_Template, id=tid)
     initial_data = {"vulnerability_ids": "\n".join(template.vulnerability_ids)}
@@ -2212,7 +2187,6 @@ def edit_template(request, tid):
     )
 
 
-@user_has_global_permission(Permissions.Finding_Delete)
 def delete_template(request, tid):
     template = get_object_or_404(Finding_Template, id=tid)
     if request.method == "POST":
@@ -2290,7 +2264,6 @@ class Original(ImageSpec):
         return response
 
 
-@user_is_authorized(Product, Permissions.Finding_Edit, "pid")
 def merge_finding_product(request, pid):
     product = get_object_or_404(Product, pk=pid)
     finding_to_update = request.GET.getlist("finding_to_update")
@@ -2470,7 +2443,6 @@ def merge_finding_product(request, pid):
         },
     )
 
-
 # bulk update and delete are combined, so we can't have the nice user_is_authorized decorator
 
 
@@ -2480,11 +2452,11 @@ def _bulk_delete_findings(request, pid, form, finding_to_update, finds, total_fi
         if pid is not None:
             product = get_object_or_404(Product, id=pid)
             user_has_permission_or_403(
-                request.user, product, Permissions.Finding_Delete,
+                request.user, product, "delete",
             )
 
         finds = get_authorized_findings_for_queryset(
-            Permissions.Finding_Delete, finds,
+            "delete", finds,
         ).distinct()
 
         skipped_find_count = total_find_count - finds.count()
@@ -2924,12 +2896,12 @@ def finding_bulk_update_all(request, pid=None):
             if pid is not None:
                 product = get_object_or_404(Product, id=pid)
                 user_has_permission_or_403(
-                    request.user, product, Permissions.Finding_Edit,
+                    request.user, product, "edit",
                 )
 
             # make sure users are not editing stuff they are not authorized for
             finds = get_authorized_findings_for_queryset(
-                Permissions.Finding_Edit, finds,
+                "edit", finds,
             ).distinct()
 
             skipped_find_count = total_find_count - finds.count()
@@ -3065,7 +3037,6 @@ def get_missing_mandatory_notetypes(finding):
     return Note_Type.objects.filter(id__in=notes_to_be_added)
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "original_id")
 @require_POST
 def mark_finding_duplicate(request, original_id, duplicate_id):
 
@@ -3134,7 +3105,6 @@ def reset_finding_duplicate_status_internal(user, duplicate_id):
     return duplicate.id
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "duplicate_id")
 @require_POST
 def reset_finding_duplicate_status(request, duplicate_id):
     checked_duplicate_id = reset_finding_duplicate_status_internal(
@@ -3216,7 +3186,6 @@ def set_finding_as_original_internal(user, finding_id, new_original_id):
     return True
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "finding_id")
 @require_POST
 def set_finding_as_original(request, finding_id, new_original_id):
     success = set_finding_as_original_internal(
@@ -3236,7 +3205,6 @@ def set_finding_as_original(request, finding_id, new_original_id):
     )
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 @require_POST
 def unlink_jira(request, fid):
     finding = get_object_or_404(Finding, id=fid)
@@ -3272,7 +3240,6 @@ def unlink_jira(request, fid):
         return HttpResponse(status=400)
 
 
-@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
 @require_POST
 def push_to_jira(request, fid):
     finding = get_object_or_404(Finding, id=fid)
diff --git a/dojo/finding_group/queries.py b/dojo/finding_group/queries.py
index 030342521b5..c615ca6be61 100644
--- a/dojo/finding_group/queries.py
+++ b/dojo/finding_group/queries.py
@@ -1,94 +1,23 @@
-from crum import get_current_user
-from django.db.models import Q, Subquery
+try:
+    from dojo.authorization.query_filters import get_auth_filter
+except ImportError:
+    def get_auth_filter(key): return None
 
-from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission
-from dojo.models import Finding_Group, Product_Group, Product_Member, Product_Type_Group, Product_Type_Member
+from dojo.models import Finding_Group
 from dojo.request_cache import cache_for_request
 
 
 # Cached: all parameters are hashable, no dynamic queryset filtering
 @cache_for_request
 def get_authorized_finding_groups(permission, user=None):
-    """Cached - returns all finding groups the user is authorized to see."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Finding_Group.objects.none()
-
-    finding_groups = Finding_Group.objects.all()
-
-    if user.is_superuser:
-        return finding_groups
-
-    if user_has_global_permission(user, permission):
-        return finding_groups
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return finding_groups.filter(
-        Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_roles))
-        | Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("finding_group.get_authorized_finding_groups")
+    if impl:
+        return impl(permission, user=user)
+    return Finding_Group.objects.all()
 
 
 def get_authorized_finding_groups_for_queryset(permission, queryset, user=None):
-    """Filters a provided queryset for authorization. Not cached due to dynamic queryset parameter."""
-    if user is None:
-        user = get_current_user()
-
-    if user is None:
-        return Finding_Group.objects.none()
-
-    if user.is_superuser:
-        return queryset
-
-    if user_has_global_permission(user, permission):
-        return queryset
-
-    roles = get_roles_for_permission(permission)
-
-    # Get authorized product/product_type IDs via subqueries
-    authorized_product_type_roles = Product_Type_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_roles = Product_Member.objects.filter(
-        user=user, role__in=roles,
-    ).values("product_id")
-
-    authorized_product_type_groups = Product_Type_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_type_id")
-
-    authorized_product_groups = Product_Group.objects.filter(
-        group__users=user, role__in=roles,
-    ).values("product_id")
-
-    # Filter using IN with Subquery - no annotations needed
-    return queryset.filter(
-        Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_roles))
-        | Q(test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups))
-        | Q(test__engagement__product_id__in=Subquery(authorized_product_groups)),
-    )
+    impl = get_auth_filter("finding_group.get_authorized_finding_groups_for_queryset")
+    if impl:
+        return impl(permission, queryset, user=user)
+    return queryset
diff --git a/dojo/finding_group/views.py b/dojo/finding_group/views.py
index afdb897e98a..5c5c78842f9 100644
--- a/dojo/finding_group/views.py
+++ b/dojo/finding_group/views.py
@@ -13,8 +13,6 @@
 from django.views.decorators.http import require_POST
 
 from dojo.authorization.authorization import user_has_permission_or_403
-from dojo.authorization.authorization_decorators import user_is_authorized
-from dojo.authorization.roles_permissions import Permissions
 from dojo.filters import (
     FindingFilter,
     FindingFilterWithoutObjectLookups,
@@ -23,14 +21,13 @@
 from dojo.finding.queries import prefetch_for_findings
 from dojo.forms import DeleteFindingGroupForm, EditFindingGroupForm, FindingBulkUpdateForm
 from dojo.jira import services as jira_services
-from dojo.models import Engagement, Finding, Finding_Group, GITHUB_PKey, Global_Role, Product
+from dojo.models import Engagement, Finding, Finding_Group, GITHUB_PKey, Product
 from dojo.product.queries import get_authorized_products
 from dojo.utils import Product_Tab, add_breadcrumb, get_page_items, get_setting, get_system_setting, get_words_for_field
 
 logger = logging.getLogger(__name__)
 
 
-@user_is_authorized(Finding_Group, Permissions.Finding_Group_View, "fgid")
 def view_finding_group(request, fgid):
     finding_group = get_object_or_404(Finding_Group, pk=fgid)
     findings = finding_group.findings.all()
@@ -46,7 +43,7 @@ def view_finding_group(request, fgid):
     if finding_group.test.engagement.product.id:
         pid = finding_group.test.engagement.product.id
         product = get_object_or_404(Product, id=pid)
-        user_has_permission_or_403(request.user, product, Permissions.Product_View)
+        user_has_permission_or_403(request.user, product, "view")
         product_tab = Product_Tab(product, title="Findings", tab="findings")
         jira_project = jira_services.get_project(product)
         github_config = GITHUB_PKey.objects.filter(product=pid).first()
@@ -54,7 +51,7 @@ def view_finding_group(request, fgid):
     elif finding_group.test.engagement.id:
         eid = finding_group.test.engagement.id
         engagement = get_object_or_404(Engagement, id=eid)
-        user_has_permission_or_403(request.user, engagement, Permissions.Engagement_View)
+        user_has_permission_or_403(request.user, engagement, "view")
         product_tab = Product_Tab(engagement.product, title=engagement.name, tab="engagements")
         jira_project = jira_services.get_project(engagement)
         github_config = GITHUB_PKey.objects.filter(product__engagement=eid).first()
@@ -121,7 +118,6 @@ def view_finding_group(request, fgid):
     })
 
 
-@user_is_authorized(Finding_Group, Permissions.Finding_Group_Delete, "fgid")
 @require_POST
 def delete_finding_group(request, fgid):
     finding_group = get_object_or_404(Finding_Group, pk=fgid)
@@ -154,7 +150,6 @@ def delete_finding_group(request, fgid):
     })
 
 
-@user_is_authorized(Finding_Group, Permissions.Finding_Group_Edit, "fgid")
 @require_POST
 def unlink_jira(request, fgid):
     logger.debug("/finding_group/%s/jira/unlink", fgid)
@@ -189,7 +184,6 @@ def unlink_jira(request, fgid):
         return HttpResponse(status=400)
 
 
-@user_is_authorized(Finding_Group, Permissions.Finding_Group_Edit, "fgid")
 @require_POST
 def push_to_jira(request, fgid):
     logger.debug("/finding_group/%s/jira/push", fgid)
@@ -299,9 +293,8 @@ def paginate_queryset(self, queryset: QuerySet[Finding_Group], request: HttpRequ
         return paginator.get_page(page_number)
 
     def get(self, request: HttpRequest) -> HttpResponse:
-        global_role = Global_Role.objects.filter(user=request.user).first()
-        products = get_authorized_products(Permissions.Product_View)
-        if request.user.is_superuser or (global_role and global_role.role):
+        products = get_authorized_products("view")
+        if request.user.is_superuser:
             finding_groups = self.get_finding_groups(request)
         elif products.exists():
             finding_groups = self.get_finding_groups(request, products)
diff --git a/dojo/fixtures/defect_dojo_sample_data.json b/dojo/fixtures/defect_dojo_sample_data.json
index 880d98778aa..7923a91dd38 100644
--- a/dojo/fixtures/defect_dojo_sample_data.json
+++ b/dojo/fixtures/defect_dojo_sample_data.json
@@ -784,9 +784,6 @@
       "add_vulnerability_id_to_jira_label": false,
       "allow_anonymous_survey_repsonse": false,
       "api_expose_error_details": false,
-      "default_group": null,
-      "default_group_email_pattern": "",
-      "default_group_role": null,
       "delete_duplicates": false,
       "disable_jira_webhook_secret": false,
       "disclaimer_notes": "",
diff --git a/dojo/fixtures/defect_dojo_sample_data_locations.json b/dojo/fixtures/defect_dojo_sample_data_locations.json
index 00a71e5b94c..0b08e888e03 100644
--- a/dojo/fixtures/defect_dojo_sample_data_locations.json
+++ b/dojo/fixtures/defect_dojo_sample_data_locations.json
@@ -790,9 +790,6 @@
       "add_vulnerability_id_to_jira_label": false,
       "allow_anonymous_survey_repsonse": false,
       "api_expose_error_details": false,
-      "default_group": null,
-      "default_group_email_pattern": "",
-      "default_group_role": null,
       "delete_duplicates": false,
       "disable_jira_webhook_secret": false,
       "disclaimer_notes": "",
diff --git a/dojo/fixtures/dojo_testdata.json b/dojo/fixtures/dojo_testdata.json
index 29b02b41de5..50707a2d2bf 100644
--- a/dojo/fixtures/dojo_testdata.json
+++ b/dojo/fixtures/dojo_testdata.json
@@ -97,7 +97,7 @@
       "first_name": "User",
       "last_name": "Five",
       "is_active": true,
-      "is_superuser": false,
+      "is_superuser": true,
       "is_staff": false,
       "last_login": null,
       "groups": [],
diff --git a/dojo/forms.py b/dojo/forms.py
index 9cffb68c6a3..e33d7ef51c4 100644
--- a/dojo/forms.py
+++ b/dojo/forms.py
@@ -17,7 +17,7 @@
 from django.contrib.auth.password_validation import validate_password
 from django.core.exceptions import ValidationError
 from django.core.validators import URLValidator
-from django.db.models import Count, Q
+from django.db.models import Count
 from django.forms import modelformset_factory
 from django.forms.widgets import Select, Widget
 from django.utils import timezone
@@ -28,7 +28,6 @@
 from tagulous.forms import TagField
 
 from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner
-from dojo.authorization.roles_permissions import Permissions
 from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add
 from dojo.engagement.queries import get_authorized_engagements
 from dojo.finding.queries import get_authorized_findings
@@ -40,7 +39,6 @@
     GITHUBFindingForm,
     GITHUBForm,
 )
-from dojo.group.queries import get_authorized_groups, get_group_member_roles
 from dojo.jira import services as jira_services
 from dojo.jira.forms import (  # noqa: F401 backward compat
     JIRA_TEMPLATE_CHOICES,
@@ -72,8 +70,6 @@
     ChoiceAnswer,
     ChoiceQuestion,
     Development_Environment,
-    Dojo_Group,
-    Dojo_Group_Member,
     Dojo_User,
     DojoMeta,
     Endpoint,
@@ -85,17 +81,12 @@
     Finding_Group,
     Finding_Template,
     General_Survey,
-    Global_Role,
     Note_Type,
     Notes,
     Objects_Product,
     Product,
     Product_API_Scan_Configuration,
-    Product_Group,
-    Product_Member,
     Product_Type,
-    Product_Type_Group,
-    Product_Type_Member,
     Question,
     Regulation,
     Risk_Acceptance,
@@ -275,57 +266,21 @@ class Meta:
         fields = ["id"]
 
 
-class Edit_Product_Type_MemberForm(forms.ModelForm):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["product_type"].disabled = True
-        self.fields["user"].queryset = Dojo_User.objects.order_by("first_name", "last_name")
-        self.fields["user"].disabled = True
-
-    class Meta:
-        model = Product_Type_Member
-        fields = ["product_type", "user", "role"]
-
-
-class Add_Product_Type_MemberForm(forms.ModelForm):
-    users = forms.ModelMultipleChoiceField(queryset=Dojo_User.objects.none(), required=True, label="Users")
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        current_members = Product_Type_Member.objects.filter(product_type=self.initial["product_type"]).values_list("user", flat=True)
-        self.fields["users"].queryset = Dojo_User.objects.exclude(
-            Q(is_superuser=True)
-            | Q(id__in=current_members)).exclude(is_active=False).order_by("first_name", "last_name")
-        self.fields["product_type"].label = labels.ORG_LABEL
-        self.fields["product_type"].disabled = True
-
-    class Meta:
-        model = Product_Type_Member
-        fields = ["product_type", "users", "role"]
-
-
-class Add_Product_Type_Member_UserForm(forms.ModelForm):
-    product_types = forms.ModelMultipleChoiceField(queryset=Product_Type.objects.none(), required=True,
-                                                   label=labels.ORG_PLURAL_LABEL)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        current_members = Product_Type_Member.objects.filter(user=self.initial["user"]).values_list("product_type", flat=True)
-        self.fields["product_types"].queryset = get_authorized_product_types(Permissions.Product_Type_Member_Add_Owner) \
-            .exclude(id__in=current_members)
-        self.fields["user"].disabled = True
-
-    class Meta:
-        model = Product_Type_Member
-        fields = ["product_types", "user", "role"]
-
+class Add_Product_Type_AuthorizedUsersForm(forms.Form):
+    users = forms.ModelMultipleChoiceField(
+        queryset=Dojo_User.objects.none(), required=True, label="Users",
+    )
 
-class Delete_Product_Type_MemberForm(Edit_Product_Type_MemberForm):
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, product_type=None, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["role"].disabled = True
-        self.fields["product_type"].label = labels.ORG_LABEL
+        self.product_type = product_type
+        current = product_type.authorized_users.values_list("pk", flat=True)
+        self.fields["users"].queryset = (
+            Dojo_User.objects.filter(is_active=True)
+            .exclude(is_superuser=True)
+            .exclude(pk__in=current)
+            .order_by("first_name", "last_name")
+        )
 
 
 class Test_TypeForm(forms.ModelForm):
@@ -378,7 +333,7 @@ class ProductForm(forms.ModelForm):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_Add_Product)
+        self.fields["prod_type"].queryset = get_authorized_product_types("add")
         self.fields["enable_product_tag_inheritance"].label = labels.ASSET_TAG_INHERITANCE_ENABLE_LABEL
         self.fields["enable_product_tag_inheritance"].help_text = labels.ASSET_TAG_INHERITANCE_ENABLE_HELP
         if prod_type_id := kwargs.get("instance", Product()).prod_type_id:  # we are editing existing instance
@@ -444,57 +399,48 @@ class Meta:
         fields = ["id"]
 
 
-class Edit_Product_MemberForm(forms.ModelForm):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["product"].disabled = True
-        self.fields["product"].label = labels.ASSET_LABEL
-        self.fields["user"].queryset = Dojo_User.objects.order_by("first_name", "last_name")
-        self.fields["user"].disabled = True
-
-    class Meta:
-        model = Product_Member
-        fields = ["product", "user", "role"]
-
-
-class Add_Product_MemberForm(forms.ModelForm):
-    users = forms.ModelMultipleChoiceField(queryset=Dojo_User.objects.none(), required=True, label="Users")
+class Add_Product_AuthorizedUsersForm(forms.Form):
+    users = forms.ModelMultipleChoiceField(
+        queryset=Dojo_User.objects.none(), required=True, label="Users",
+    )
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, product=None, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["product"].disabled = True
-        self.fields["product"].label = labels.ASSET_LABEL
-        current_members = Product_Member.objects.filter(product=self.initial["product"]).values_list("user", flat=True)
-        self.fields["users"].queryset = Dojo_User.objects.exclude(
-            Q(is_superuser=True)
-            | Q(id__in=current_members)).exclude(is_active=False).order_by("first_name", "last_name")
-
-    class Meta:
-        model = Product_Member
-        fields = ["product", "users", "role"]
+        self.product = product
+        current = product.authorized_users.values_list("pk", flat=True)
+        self.fields["users"].queryset = (
+            Dojo_User.objects.filter(is_active=True)
+            .exclude(is_superuser=True)
+            .exclude(pk__in=current)
+            .order_by("first_name", "last_name")
+        )
 
 
-class Add_Product_Member_UserForm(forms.ModelForm):
-    products = forms.ModelMultipleChoiceField(queryset=Product.objects.none(), required=True,
-                                              label=labels.ASSET_PLURAL_LABEL)
+class Authorize_User_For_ProductsForm(forms.Form):
+    products = forms.ModelMultipleChoiceField(
+        queryset=Product.objects.none(), required=True, label=labels.ASSET_PLURAL_LABEL,
+    )
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, user=None, **kwargs):
         super().__init__(*args, **kwargs)
-        current_members = Product_Member.objects.filter(user=self.initial["user"]).values_list("product", flat=True)
-        self.fields["products"].queryset = get_authorized_products(Permissions.Product_Member_Add_Owner) \
-            .exclude(id__in=current_members)
-        self.fields["user"].disabled = True
+        self.user = user
+        # Show products the user is not already directly authorized for.
+        self.fields["products"].queryset = (
+            Product.objects.exclude(authorized_users=user).order_by("name")
+        )
 
-    class Meta:
-        model = Product_Member
-        fields = ["products", "user", "role"]
 
+class Authorize_User_For_ProductTypesForm(forms.Form):
+    product_types = forms.ModelMultipleChoiceField(
+        queryset=Product_Type.objects.none(), required=True, label=labels.ORG_PLURAL_LABEL,
+    )
 
-class Delete_Product_MemberForm(Edit_Product_MemberForm):
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, user=None, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["role"].disabled = True
+        self.user = user
+        self.fields["product_types"].queryset = (
+            Product_Type.objects.exclude(authorized_users=user).order_by("name")
+        )
 
 
 class NoteTypeForm(forms.ModelForm):
@@ -971,7 +917,7 @@ def __init__(self, *args, **kwargs):
             # logger.debug('setting default expiration_date: %s', expiration_date)
             self.fields["expiration_date"].initial = expiration_date
         # self.fields['path'].help_text = 'Existing proof uploaded: %s' % self.instance.filename() if self.instance.filename() else 'None'
-        self.fields["accepted_findings"].queryset = get_authorized_findings(Permissions.Risk_Acceptance)
+        self.fields["accepted_findings"].queryset = get_authorized_findings("edit")
         if disclaimer := get_system_setting("disclaimer_notes"):
             self.disclaimer = disclaimer.strip()
 
@@ -1028,7 +974,7 @@ class Meta:
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["accepted_findings"].queryset = get_authorized_findings(Permissions.Risk_Acceptance)
+        self.fields["accepted_findings"].queryset = get_authorized_findings("edit")
 
 
 class CheckForm(forms.ModelForm):
@@ -1101,11 +1047,11 @@ def __init__(self, *args, **kwargs):
 
         if product:
             self.fields["preset"] = forms.ModelChoiceField(help_text="Settings and notes for performing this engagement.", required=False, queryset=Engagement_Presets.objects.filter(product=product))
-            self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, Permissions.Product_View).filter(is_active=True)
+            self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, "view").filter(is_active=True)
         else:
-            self.fields["lead"].queryset = get_authorized_users(Permissions.Engagement_View).filter(is_active=True)
+            self.fields["lead"].queryset = get_authorized_users("view").filter(is_active=True)
 
-        self.fields["product"].queryset = get_authorized_products(Permissions.Engagement_Add)
+        self.fields["product"].queryset = get_authorized_products("add")
 
         # Don't show CICD fields on a interactive engagement
         if cicd is False:
@@ -1179,10 +1125,10 @@ def __init__(self, *args, **kwargs):
 
         if obj:
             product = get_product(obj)
-            self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, Permissions.Product_View).filter(is_active=True)
+            self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, "view").filter(is_active=True)
             self.fields["api_scan_configuration"].queryset = Product_API_Scan_Configuration.objects.filter(product=product)
         else:
-            self.fields["lead"].queryset = get_authorized_users(Permissions.Test_View).filter(is_active=True)
+            self.fields["lead"].queryset = get_authorized_users("view").filter(is_active=True)
 
     def is_valid(self):
         valid = super().is_valid()
@@ -1592,7 +1538,7 @@ def __init__(self, *args, **kwargs):
             if self.instance and self.instance.pk:
                 self.fields["endpoints"].initial = self.instance.endpoints.all()
 
-        self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Finding_Edit)
+        self.fields["mitigated_by"].queryset = get_authorized_users("edit")
 
         # do not show checkbox if finding is not accepted and simple risk acceptance is disabled
         # if checked, always show to allow unaccept also with full risk acceptance enabled
@@ -2004,7 +1950,7 @@ def __init__(self, *args, **kwargs):
             product = kwargs.pop("product")
         super().__init__(*args, **kwargs)
         self.fields["product"] = forms.ModelChoiceField(
-            queryset=get_authorized_products(Permissions.Location_Add),
+            queryset=get_authorized_products("add"),
             label=labels.ASSET_LABEL,
             help_text=labels.ASSET_ENDPOINT_HELP)
         if product is not None:
@@ -2126,7 +2072,7 @@ def __init__(self, *args, **kwargs):
             self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True)
 
         if self.can_edit_mitigated_data:
-            self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Finding_Edit)
+            self.fields["mitigated_by"].queryset = get_authorized_users("edit")
             self.fields["mitigated"].initial = self.instance.mitigated
             self.fields["mitigated_by"].initial = self.instance.mitigated_by
         if disclaimer := get_system_setting("disclaimer_notes"):
@@ -2234,9 +2180,9 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         # Get the list of users
         if finding is not None:
-            users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, Permissions.Finding_Edit)
+            users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit")
         else:
-            users = get_authorized_users(Permissions.Finding_Edit).filter(is_active=True)
+            users = get_authorized_users("edit").filter(is_active=True)
         # Remove the current user
         if user is not None:
             users = users.exclude(id=user.id)
@@ -2337,180 +2283,6 @@ def __init__(self, *args, **kwargs):
             del self.fields["exclude_product_types"]
 
 
-class DojoGroupForm(forms.ModelForm):
-
-    name = forms.CharField(max_length=255, required=True)
-    description = forms.CharField(widget=forms.Textarea(attrs={}), required=False)
-
-    class Meta:
-        model = Dojo_Group
-        fields = ["name", "description"]
-        exclude = ["users"]
-
-
-class DeleteGroupForm(forms.ModelForm):
-    id = forms.IntegerField(required=True,
-                            widget=forms.widgets.HiddenInput())
-
-    class Meta:
-        model = Dojo_Group
-        fields = ["id"]
-
-
-class Add_Group_MemberForm(forms.ModelForm):
-    users = forms.ModelMultipleChoiceField(queryset=Dojo_Group_Member.objects.none(), required=True, label="Users")
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["group"].disabled = True
-        current_members = Dojo_Group_Member.objects.filter(group=self.initial["group"]).values_list("user", flat=True)
-        self.fields["users"].queryset = Dojo_User.objects.exclude(
-            Q(is_superuser=True)
-            | Q(id__in=current_members)).exclude(is_active=False).order_by("first_name", "last_name")
-        self.fields["role"].queryset = get_group_member_roles()
-
-    class Meta:
-        model = Dojo_Group_Member
-        fields = ["group", "users", "role"]
-
-
-class Add_Group_Member_UserForm(forms.ModelForm):
-    groups = forms.ModelMultipleChoiceField(queryset=Dojo_Group.objects.none(), required=True, label="Groups")
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["user"].disabled = True
-        current_groups = Dojo_Group_Member.objects.filter(user=self.initial["user"]).values_list("group", flat=True)
-        self.fields["groups"].queryset = Dojo_Group.objects.exclude(id__in=current_groups)
-        self.fields["role"].queryset = get_group_member_roles()
-
-    class Meta:
-        model = Dojo_Group_Member
-        fields = ["groups", "user", "role"]
-
-
-class Edit_Group_MemberForm(forms.ModelForm):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["group"].disabled = True
-        self.fields["user"].disabled = True
-        self.fields["role"].queryset = get_group_member_roles()
-
-    class Meta:
-        model = Dojo_Group_Member
-        fields = ["group", "user", "role"]
-
-
-class Delete_Group_MemberForm(Edit_Group_MemberForm):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["role"].disabled = True
-
-
-class Add_Product_GroupForm(forms.ModelForm):
-    groups = forms.ModelMultipleChoiceField(queryset=Dojo_Group.objects.none(), required=True, label="Groups")
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["product"].disabled = True
-        self.fields["product"].label = labels.ASSET_LABEL
-        current_groups = Product_Group.objects.filter(product=self.initial["product"]).values_list("group", flat=True)
-        authorized_groups = get_authorized_groups(Permissions.Group_View)
-        authorized_groups = authorized_groups.exclude(id__in=current_groups)
-        self.fields["groups"].queryset = authorized_groups
-
-    class Meta:
-        model = Product_Group
-        fields = ["product", "groups", "role"]
-
-
-class Add_Product_Group_GroupForm(forms.ModelForm):
-    products = forms.ModelMultipleChoiceField(queryset=Product.objects.none(), required=True,
-                                              label=labels.ASSET_PLURAL_LABEL)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        current_members = Product_Group.objects.filter(group=self.initial["group"]).values_list("product", flat=True)
-        self.fields["products"].queryset = get_authorized_products(Permissions.Product_Member_Add_Owner) \
-            .exclude(id__in=current_members)
-        self.fields["group"].disabled = True
-
-    class Meta:
-        model = Product_Group
-        fields = ["products", "group", "role"]
-
-
-class Edit_Product_Group_Form(forms.ModelForm):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["product"].disabled = True
-        self.fields["product"].label = labels.ASSET_LABEL
-        self.fields["group"].disabled = True
-
-    class Meta:
-        model = Product_Group
-        fields = ["product", "group", "role"]
-
-
-class Delete_Product_GroupForm(Edit_Product_Group_Form):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["role"].disabled = True
-
-
-class Add_Product_Type_GroupForm(forms.ModelForm):
-    groups = forms.ModelMultipleChoiceField(queryset=Dojo_Group.objects.none(), required=True, label="Groups")
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        current_groups = Product_Type_Group.objects.filter(product_type=self.initial["product_type"]).values_list("group", flat=True)
-        authorized_groups = get_authorized_groups(Permissions.Group_View)
-        authorized_groups = authorized_groups.exclude(id__in=current_groups)
-        self.fields["groups"].queryset = authorized_groups
-        self.fields["product_type"].disabled = True
-        self.fields["product_type"].label = labels.ORG_LABEL
-
-    class Meta:
-        model = Product_Type_Group
-        fields = ["product_type", "groups", "role"]
-
-
-class Add_Product_Type_Group_GroupForm(forms.ModelForm):
-    product_types = forms.ModelMultipleChoiceField(queryset=Product_Type.objects.none(), required=True,
-                                                   label=labels.ORG_PLURAL_LABEL)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        current_members = Product_Type_Group.objects.filter(group=self.initial["group"]).values_list("product_type", flat=True)
-        self.fields["product_types"].queryset = get_authorized_product_types(Permissions.Product_Type_Member_Add_Owner) \
-            .exclude(id__in=current_members)
-        self.fields["group"].disabled = True
-
-    class Meta:
-        model = Product_Type_Group
-        fields = ["product_types", "group", "role"]
-
-
-class Edit_Product_Type_Group_Form(forms.ModelForm):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["product_type"].disabled = True
-        self.fields["product_type"].label = labels.ORG_LABEL
-        self.fields["group"].disabled = True
-
-    class Meta:
-        model = Product_Type_Group
-        fields = ["product_type", "group", "role"]
-
-
-class Delete_Product_Type_GroupForm(Edit_Product_Type_Group_Form):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields["role"].disabled = True
-
-
 class DojoUserForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -2573,12 +2345,13 @@ class AddDojoUserForm(forms.ModelForm):
 
     class Meta:
         model = Dojo_User
-        fields = ["username", "password", "first_name", "last_name", "email", "is_active", "is_superuser"]
+        fields = ["username", "password", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         current_user = get_current_user()
         if not current_user.is_superuser:
+            self.fields["is_staff"].disabled = True
             self.fields["is_superuser"].disabled = True
         self.fields["password"].help_text = get_password_requirements_string()
 
@@ -2588,12 +2361,13 @@ class EditDojoUserForm(forms.ModelForm):
 
     class Meta:
         model = Dojo_User
-        fields = ["username", "first_name", "last_name", "email", "is_active", "is_superuser"]
+        fields = ["username", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         current_user = get_current_user()
         if not current_user.is_superuser:
+            self.fields["is_staff"].disabled = True
             self.fields["is_superuser"].disabled = True
 
 
@@ -2619,7 +2393,7 @@ class Meta:
         # Swap order: password_last_reset before token_last_reset
         field_order = [
             "title", "phone_number", "cell_number", "twitter_username", "github_username",
-            "slack_username", "block_execution", "force_password_reset", "reset_api_token",
+            "slack_username", "ui_use_tailwind", "block_execution", "force_password_reset", "reset_api_token",
             "password_last_reset", "token_last_reset",
         ]
 
@@ -2653,19 +2427,6 @@ def __init__(self, *args, **kwargs):
             self.fields.pop("reset_api_token", None)
 
 
-class GlobalRoleForm(forms.ModelForm):
-    class Meta:
-        model = Global_Role
-        exclude = ["user", "group"]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        current_user = get_current_user()
-        self.fields["role"].help_text = labels.ASSET_GLOBAL_ROLE_HELP
-        if not current_user.is_superuser:
-            self.fields["role"].disabled = True
-
-
 def get_years():
     now = timezone.now()
     return [(now.year, now.year), (now.year - 1, now.year - 1), (now.year - 2, now.year - 2)]
@@ -2687,7 +2448,7 @@ class ProductTypeCountsForm(ProductCountsFormBase):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["product_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View)
+        self.fields["product_type"].queryset = get_authorized_product_types("view")
 
 
 class ProductTagCountsForm(ProductCountsFormBase):
@@ -2699,7 +2460,7 @@ class ProductTagCountsForm(ProductCountsFormBase):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        prods = get_authorized_products(Permissions.Product_View)
+        prods = get_authorized_products("view")
         tags_available_to_user = Product.tags.tag_model.objects.filter(product__in=prods)
         self.fields["product_tag"].queryset = tags_available_to_user
 
@@ -3017,7 +2778,6 @@ class SystemSettingsForm(forms.ModelForm):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["default_group_role"].queryset = get_group_member_roles()
 
         self.fields["enable_product_tracking_files"].label = labels.SETTINGS_TRACKED_FILES_ENABLE_LABEL
         self.fields["enable_product_tracking_files"].help_text = labels.SETTINGS_TRACKED_FILES_ENABLE_HELP
@@ -3045,7 +2805,7 @@ def clean(self):
 
     class Meta:
         model = System_Settings
-        fields = "__all__"
+        exclude = ()
 
 
 class BenchmarkForm(forms.ModelForm):
@@ -3482,7 +3242,7 @@ def __init__(self, *args, **kwargs):
             assignee = kwargs.pop("asignees")
         super().__init__(*args, **kwargs)
         if assignee is None:
-            self.fields["assignee"] = forms.ModelChoiceField(queryset=get_authorized_users(Permissions.Engagement_View), empty_label="Not Assigned", required=False)
+            self.fields["assignee"] = forms.ModelChoiceField(queryset=get_authorized_users("view"), empty_label="Not Assigned", required=False)
         else:
             self.fields["assignee"].initial = assignee
 
@@ -3500,7 +3260,7 @@ class AddEngagementForm(forms.Form):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["product"].queryset = get_authorized_products(Permissions.Engagement_Add)
+        self.fields["product"].queryset = get_authorized_products("add")
 
 
 class ExistingEngagementForm(forms.Form):
@@ -3512,7 +3272,7 @@ class ExistingEngagementForm(forms.Form):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_Edit).order_by("-target_start")
+        self.fields["engagement"].queryset = get_authorized_engagements("edit").order_by("-target_start")
 
 
 class ConfigurationPermissionsForm(forms.Form):
diff --git a/dojo/github/templates/dojo/delete_github.html b/dojo/github/templates/dojo/delete_github.html
index 3b7ccb323b5..9ef0bd2d494 100644
--- a/dojo/github/templates/dojo/delete_github.html
+++ b/dojo/github/templates/dojo/delete_github.html
@@ -15,7 +15,7 @@ 

Danger Zone

{% else %}

No relationships found.

{% endif %} -
+ {% csrf_token %} {{ form }} diff --git a/dojo/github/templates/dojo/new_github.html b/dojo/github/templates/dojo/new_github.html index effd51857b7..a3ee8682e7d 100644 --- a/dojo/github/templates/dojo/new_github.html +++ b/dojo/github/templates/dojo/new_github.html @@ -2,7 +2,7 @@ {% block content %} {{ block.super }}

Add a GitHub Configuration

- {% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=gform %}
diff --git a/dojo/github/ui/views.py b/dojo/github/ui/views.py index 4107e54a0a8..6a668baf816 100644 --- a/dojo/github/ui/views.py +++ b/dojo/github/ui/views.py @@ -10,7 +10,6 @@ from django.urls import reverse from django.views.decorators.csrf import csrf_exempt -from dojo.authorization.authorization_decorators import user_is_configuration_authorized from dojo.github.models import GITHUB_Conf from dojo.github.services import validate_github_credentials from dojo.github.ui.forms import DeleteGITHUBConfForm, GITHUBForm @@ -24,7 +23,6 @@ def webhook(request): return HttpResponse("") -@user_is_configuration_authorized("dojo.add_github_conf") def new_github(request): if request.method == "POST": gform = GITHUBForm(request.POST, instance=GITHUB_Conf()) @@ -55,7 +53,6 @@ def new_github(request): {"gform": gform}) -@user_is_configuration_authorized("dojo.view_github_conf") def github(request): confs = GITHUB_Conf.objects.all() add_breadcrumb(title="GitHub List", top_level=not len(request.GET), request=request) @@ -65,7 +62,6 @@ def github(request): }) -@user_is_configuration_authorized("dojo.delete_github_conf") def delete_github(request, tid): github_instance = get_object_or_404(GITHUB_Conf, pk=tid) # eng = test.engagement diff --git a/dojo/group/__init__.py b/dojo/group/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/dojo/group/queries.py b/dojo/group/queries.py deleted file mode 100644 index 11a6718bf62..00000000000 --- a/dojo/group/queries.py +++ /dev/null @@ -1,69 +0,0 @@ -from crum import get_current_user -from django.db.models import Subquery - -from dojo.authorization.authorization import get_roles_for_permission, user_has_configuration_permission -from dojo.authorization.roles_permissions import Permissions -from dojo.models import Dojo_Group, Dojo_Group_Member, Product_Group, Product_Type_Group, Role -from dojo.request_cache import cache_for_request - - -# Cached: all parameters are hashable, no dynamic queryset filtering -@cache_for_request -def get_authorized_groups(permission): - user = get_current_user() - - if user is None: - return Dojo_Group.objects.none() - - if user.is_superuser: - return Dojo_Group.objects.all().order_by("name") - - # Check for the case of the view_group config permission - if user_has_configuration_permission(user, "auth.view_group") or user_has_configuration_permission(user, "auth.add_group"): - return Dojo_Group.objects.all().order_by("name") - - roles = get_roles_for_permission(permission) - - # Get authorized group IDs via subquery - authorized_roles = Dojo_Group_Member.objects.filter( - user=user, role__in=roles, - ).values("group_id") - - # Filter using IN with Subquery - no annotations needed - return Dojo_Group.objects.filter( - pk__in=Subquery(authorized_roles), - ).order_by("name") - - -def get_authorized_group_members(permission): - user = get_current_user() - - if user is None: - return Dojo_Group_Member.objects.none() - - if user.is_superuser: - return Dojo_Group_Member.objects.all().order_by("id").select_related("role") - - groups = get_authorized_groups(permission) - return Dojo_Group_Member.objects.filter(group__in=groups).order_by("id").select_related("role") - - -def get_authorized_group_members_for_user(user): - groups = get_authorized_groups(Permissions.Group_View) - return Dojo_Group_Member.objects.filter(user=user, group__in=groups).order_by("group__name").select_related("role", "group") - - -def get_group_members_for_group(group): - return Dojo_Group_Member.objects.filter(group=group).select_related("role") - - -def get_product_groups_for_group(group): - return Product_Group.objects.filter(group=group).select_related("role") - - -def get_product_type_groups_for_group(group): - return Product_Type_Group.objects.filter(group=group).select_related("role") - - -def get_group_member_roles(): - return Role.objects.exclude(name="API_Importer").exclude(name="Writer") diff --git a/dojo/group/urls.py b/dojo/group/urls.py deleted file mode 100644 index 2839846dbaa..00000000000 --- a/dojo/group/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import re_path - -from dojo.group import views - -urlpatterns = [ - re_path(r"^group$", views.ListGroups.as_view(), name="groups"), - re_path(r"^group/add$", views.AddGroup.as_view(), name="add_group"), - re_path(r"^group/(?P\d+)$", views.ViewGroup.as_view(), name="view_group"), - re_path(r"^group/(?P\d+)/edit$", views.EditGroup.as_view(), name="edit_group"), - re_path(r"^group/(?P\d+)/delete$", views.DeleteGroup.as_view(), name="delete_group"), - re_path(r"^group/(?P\d+)/add_product_group$", views.add_product_group, name="add_product_group_group"), - re_path(r"^group/(?P\d+)/add_product_type_group$", views.add_product_type_group, name="add_product_type_group_group"), - re_path(r"^group/(?P\d+)/add_group_member$", views.add_group_member, name="add_group_member"), - re_path(r"group/member/(?P\d+)/edit_group_member$", views.edit_group_member, name="edit_group_member"), - re_path(r"group/member/(?P\d+)/delete_group_member$", views.delete_group_member, name="delete_group_member"), - re_path(r"^group/(?P\d+)/edit_permissions$", views.edit_permissions, name="edit_group_permissions"), -] diff --git a/dojo/group/utils.py b/dojo/group/utils.py deleted file mode 100644 index bf3fd65e9c5..00000000000 --- a/dojo/group/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -from crum import get_current_user -from django.contrib.auth.models import Group -from django.db.models.signals import post_delete, post_save -from django.dispatch import receiver - -from dojo.models import Dojo_Group, Dojo_Group_Member, Role - - -def get_auth_group_name(group, attempt=0): - if attempt > 999: - msg = f"Cannot find name for authorization group for Dojo_Group {group.name}, aborted after 999 attempts." - raise Exception(msg) - auth_group_name = group.name if attempt == 0 else group.name + "_" + str(attempt) - - try: - # Attempt to fetch an existing group before moving forward with the real operation - _ = Group.objects.get(name=auth_group_name) - return get_auth_group_name(group, attempt + 1) - except Group.DoesNotExist: - return auth_group_name - - -@receiver(post_save, sender=Dojo_Group) -def group_post_save_handler(sender, **kwargs): - created = kwargs.pop("created") - group = kwargs.pop("instance") - if created: - # Create authentication group - auth_group = Group(name=get_auth_group_name(group)) - auth_group.save() - group.auth_group = auth_group - group.save() - user = get_current_user() - if user and not group.social_provider: - # Add the current user as the owner of the group - member = Dojo_Group_Member() - member.user = user - member.group = group - member.role = Role.objects.get(is_owner=True) - member.save() - # Add user to authentication group as well - auth_group.user_set.add(user) - - -@receiver(post_delete, sender=Dojo_Group) -def group_post_delete_handler(sender, **kwargs): - group = kwargs.pop("instance") - # Authorization group doesn't get deleted automatically - if group.auth_group: - group.auth_group.delete() - - -@receiver(post_save, sender=Dojo_Group_Member) -def group_member_post_save_handler(sender, **kwargs): - created = kwargs.pop("created") - group_member = kwargs.pop("instance") - if created: - # Add user to authentication group as well - if group_member.group.auth_group: - group_member.group.auth_group.user_set.add(group_member.user) - - -@receiver(post_delete, sender=Dojo_Group_Member) -def group_member_post_delete_handler(sender, **kwargs): - group_member = kwargs.pop("instance") - # Remove user from the authentication group as well - if group_member.group.auth_group: - group_member.group.auth_group.user_set.remove(group_member.user) diff --git a/dojo/group/views.py b/dojo/group/views.py deleted file mode 100644 index df1e6e815b2..00000000000 --- a/dojo/group/views.py +++ /dev/null @@ -1,592 +0,0 @@ -import logging - -from django.contrib import messages -from django.contrib.admin.utils import NestedObjects -from django.contrib.auth.decorators import user_passes_test -from django.contrib.auth.models import Group -from django.core.exceptions import PermissionDenied -from django.db import DEFAULT_DB_ALIAS -from django.db.models.deletion import RestrictedError -from django.db.models.query import QuerySet -from django.http import HttpRequest, HttpResponseRedirect -from django.shortcuts import get_object_or_404, render -from django.urls import reverse -from django.views import View - -from dojo.authorization.authorization import ( - user_has_configuration_permission, - user_has_permission, - user_has_permission_or_403, -) -from dojo.authorization.authorization_decorators import user_is_authorized, user_is_configuration_authorized -from dojo.authorization.roles_permissions import Permissions -from dojo.filters import GroupFilter -from dojo.forms import ( - Add_Group_MemberForm, - Add_Product_Group_GroupForm, - Add_Product_Type_Group_GroupForm, - ConfigurationPermissionsForm, - Delete_Group_MemberForm, - DeleteGroupForm, - DojoGroupForm, - Edit_Group_MemberForm, - GlobalRoleForm, -) -from dojo.group.queries import ( - get_authorized_groups, - get_group_members_for_group, - get_product_groups_for_group, - get_product_type_groups_for_group, -) -from dojo.group.utils import get_auth_group_name -from dojo.labels import get_labels -from dojo.models import Dojo_Group, Dojo_Group_Member, Global_Role, Product_Group, Product_Type_Group -from dojo.utils import ( - add_breadcrumb, - get_page_items, - get_setting, - is_title_in_breadcrumbs, - redirect_to_return_url_or_else, -) - -logger = logging.getLogger(__name__) - - -labels = get_labels() - - -class ListGroups(View): - def get_groups(self): - return get_authorized_groups(Permissions.Group_View) - - def get_initial_context(self, request: HttpRequest, groups: QuerySet[Dojo_Group]): - filtered_groups = GroupFilter(request.GET, queryset=groups) - return { - "name": "All Groups", - "filtered": filtered_groups, - "groups": get_page_items(request, filtered_groups.qs, 25), - } - - def get_template(self): - return "dojo/groups.html" - - def get(self, request: HttpRequest): - # quick permission check - if not user_has_configuration_permission(request.user, "auth.view_group"): - raise PermissionDenied - # Fetch the groups - groups = self.get_groups() - # Set up the initial context - context = self.get_initial_context(request, groups) - # Add a breadcrumb - add_breadcrumb(title="All Groups", top_level=True, request=request) - # Render the page - return render(request, self.get_template(), context) - - -class ViewGroup(View): - def get_group(self, group_id: int): - return get_object_or_404(Dojo_Group, id=group_id) - - def get_initial_context(self, group: Dojo_Group): - return { - "group": group, - "products": get_product_groups_for_group(group), - "product_types": get_product_type_groups_for_group(group), - "group_members": get_group_members_for_group(group), - } - - def set_configuration_permissions(self, group: Dojo_Group, context: dict): - # Create authorization group if it doesn't exist and add product members - if not group.auth_group: - auth_group = Group(name=get_auth_group_name(group)) - auth_group.save() - group.auth_group = auth_group - members = group.users.all() - for member in members: - auth_group.user_set.add(member) - group.save() - # create the config permissions form - context["configuration_permission_form"] = ConfigurationPermissionsForm(group=group) - - return context - - def get_template(self): - return "dojo/view_group.html" - - def get(self, request: HttpRequest, group_id: int): - # Fetch the group - group = self.get_group(group_id) - # quick permission check - if not user_has_configuration_permission(request.user, "auth.view_group"): - raise PermissionDenied - user_has_permission_or_403(request.user, group, Permissions.Group_View) - # Set up the initial context - context = self.get_initial_context(group) - # Set up the config permissions - context = self.set_configuration_permissions(group, context) - # Add a breadcrumb - add_breadcrumb(title="View Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - -class EditGroup(View): - def get_group(self, group_id: int): - return get_object_or_404(Dojo_Group, id=group_id) - - def get_global_role(self, group: Dojo_Group): - # Try to pull the global role from the group object - return group.global_role if hasattr(group, "global_role") else None - - def get_group_form(self, request: HttpRequest, group: Dojo_Group): - # Set up the args for the form - args = [request.POST] if request.method == "POST" else [] - # Set the initial form args - kwargs = { - "instance": group, - } - - return DojoGroupForm(*args, **kwargs) - - def get_global_role_form(self, request: HttpRequest, global_role: Global_Role): - # Set up the args for the form - args = [request.POST] if request.method == "POST" else [] - # Set the initial form args - kwargs = {} - # Add the global role to te kwargs if it is present - if global_role: - kwargs["instance"] = global_role - - return GlobalRoleForm(*args, **kwargs) - - def get_initial_context(self, request: HttpRequest, group: Dojo_Group, global_role: Global_Role): - return { - "form": self.get_group_form(request, group), - "global_role_form": self.get_global_role_form(request, global_role), - "previous_global_role": global_role.role if global_role else None, - } - - def process_forms(self, request: HttpRequest, group: Dojo_Group, context: dict): - # Validate the forms - if context["form"].is_valid() and context["global_role_form"].is_valid(): - # Determine if the previous global roles was changed with proper authorization - if context["global_role_form"].cleaned_data["role"] != context["previous_global_role"] and not request.user.is_superuser: - messages.add_message( - request, - messages.WARNING, - "Only superusers are allowed to change the global role.", - extra_tags="alert-warning") - else: - context["form"].save() - global_role = context["global_role_form"].save(commit=False) - global_role.group = group - global_role.save() - messages.add_message( - request, - messages.SUCCESS, - "Group saved successfully.", - extra_tags="alert-success") - - return request, True - messages.add_message( - request, - messages.ERROR, - "Group was not saved successfully.", - extra_tags="alert_danger") - - return request, False - - def get_template(self): - return "dojo/add_group.html" - - def get(self, request: HttpRequest, group_id: int): - # Fetch the group and global role - group = self.get_group(group_id) - global_role = self.get_global_role(group) - # quick permission check - user_has_permission_or_403(request.user, group, Permissions.Group_Edit) - # Set up the initial context - context = self.get_initial_context(request, group, global_role) - # Add a breadcrumb - add_breadcrumb(title="Edit Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - def post(self, request: HttpRequest, group_id: int): - # Fetch the group and global role - group = self.get_group(group_id) - global_role = self.get_global_role(group) - # quick permission check - user_has_permission_or_403(request.user, group, Permissions.Group_Edit) - # Set up the initial context - context = self.get_initial_context(request, group, global_role) - # Process the forms - request, success = self.process_forms(request, group, context) - # Handle the case of a successful form - if success: - return redirect_to_return_url_or_else(request, reverse("view_group", args=(group_id,))) - # Add a breadcrumb - add_breadcrumb(title="Edit Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - -class DeleteGroup(View): - def get_group(self, group_id: int): - return get_object_or_404(Dojo_Group, id=group_id) - - def get_group_form(self, request: HttpRequest, group: Dojo_Group): - # Set up the args for the form - args = [request.POST] if request.method == "POST" else [] - # Set the initial form args - kwargs = { - "instance": group, - } - - return DeleteGroupForm(*args, **kwargs) - - def get_initial_context(self, request: HttpRequest, group: Dojo_Group): - # Add the related objects to the delete page - rels = ["Previewing the relationships has been disabled.", ""] - display_preview = get_setting("DELETE_PREVIEW") - if display_preview: - collector = NestedObjects(using=DEFAULT_DB_ALIAS) - collector.collect([group]) - rels = collector.nested() - return { - "form": self.get_group_form(request, group), - "to_delete": group, - "rels": rels, - - } - - def process_forms(self, request: HttpRequest, group: Dojo_Group, context: dict): - # Validate the forms - if context["form"].is_valid(): - try: - group.delete() - messages.add_message( - request, - messages.SUCCESS, - "Group and relationships successfully removed.", - extra_tags="alert-success") - except RestrictedError as err: - messages.add_message( - request, - messages.WARNING, - f"Group cannot be deleted: {err}", - extra_tags="alert-warning", - ) - return request, False - - return request, True - return request, False - - def get_template(self): - return "dojo/delete_group.html" - - def get(self, request: HttpRequest, group_id: int): - # Fetch the group and global role - group = self.get_group(group_id) - # quick permission check - user_has_permission_or_403(request.user, group, Permissions.Group_Delete) - # Set up the initial context - context = self.get_initial_context(request, group) - # Add a breadcrumb - add_breadcrumb(title="Delete Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - def post(self, request: HttpRequest, group_id: int): - # Fetch the group and global role - group = self.get_group(group_id) - # quick permission check - user_has_permission_or_403(request.user, group, Permissions.Group_Delete) - # Set up the initial context - context = self.get_initial_context(request, group) - # Process the forms - request, success = self.process_forms(request, group, context) - # Handle the case of a successful form - if success: - return redirect_to_return_url_or_else(request, reverse("groups")) - # Add a breadcrumb - add_breadcrumb(title="Delete Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - -class AddGroup(View): - def get_group_form(self, request: HttpRequest): - # Set up the args for the form - args = [request.POST] if request.method == "POST" else [] - # Set the initial form args - kwargs = {} - - return DojoGroupForm(*args, **kwargs) - - def get_global_role_form(self, request: HttpRequest): - # Set up the args for the form - args = [request.POST] if request.method == "POST" else [] - # Set the initial form args - kwargs = {} - - return GlobalRoleForm(*args, **kwargs) - - def get_initial_context(self, request: HttpRequest): - return { - "form": self.get_group_form(request), - "global_role_form": self.get_global_role_form(request), - } - - def process_forms(self, request: HttpRequest, context: dict): - group = None - # Validate the forms - if context["form"].is_valid() and context["global_role_form"].is_valid(): - if context["global_role_form"].cleaned_data["role"] is not None and not request.user.is_superuser: - messages.add_message( - request, - messages.ERROR, - "Only superusers are allowed to set global role.", - extra_tags="alert-warning") - else: - group = context["form"].save() - global_role = context["global_role_form"].save(commit=False) - global_role.group = group - global_role.save() - messages.add_message( - request, - messages.SUCCESS, - "Group was added successfully.", - extra_tags="alert-success") - return request, group, True - else: - messages.add_message( - request, - messages.ERROR, - "Group was not added successfully.", - extra_tags="alert-danger") - - return request, group, False - - def get_template(self): - return "dojo/add_group.html" - - def get(self, request: HttpRequest): - # quick permission check - if not user_has_configuration_permission(request.user, "auth.add_group"): - raise PermissionDenied - # Set up the initial context - context = self.get_initial_context(request) - # Add a breadcrumb - add_breadcrumb(title="Add Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - def post(self, request: HttpRequest): - # quick permission check - if not user_has_configuration_permission(request.user, "auth.add_group"): - raise PermissionDenied - # Set up the initial context - context = self.get_initial_context(request) - # Process the forms - request, group, success = self.process_forms(request, context) - # Handle the case of a successful form - if success: - return redirect_to_return_url_or_else(request, reverse("view_group", args=(group.id,))) - # Add a breadcrumb - add_breadcrumb(title="Add Group", top_level=False, request=request) - # Render the page - return render(request, self.get_template(), context) - - -@user_is_authorized(Dojo_Group, Permissions.Group_Manage_Members, "gid") -def add_group_member(request, gid): - group = get_object_or_404(Dojo_Group, id=gid) - groupform = Add_Group_MemberForm(initial={"group": group.id}) - - if request.method == "POST": - groupform = Add_Group_MemberForm(request.POST, initial={"group": group.id}) - if groupform.is_valid(): - if groupform.cleaned_data["role"].is_owner and not user_has_permission(request.user, group, Permissions.Group_Add_Owner): - messages.add_message(request, - messages.WARNING, - "You are not permitted to add users as owners.", - extra_tags="alert-warning") - else: - if "users" in groupform.cleaned_data and len(groupform.cleaned_data["users"]) > 0: - for user in groupform.cleaned_data["users"]: - existing_users = Dojo_Group_Member.objects.filter(group=group, user=user) - if existing_users.count() == 0: - group_member = Dojo_Group_Member() - group_member.group = group - group_member.user = user - group_member.role = groupform.cleaned_data["role"] - group_member.save() - messages.add_message(request, - messages.SUCCESS, - "Group members added successfully.", - extra_tags="alert-success") - return HttpResponseRedirect(reverse("view_group", args=(gid, ))) - - add_breadcrumb(title="Add Group Member", top_level=False, request=request) - return render(request, "dojo/new_group_member.html", { - "group": group, - "form": groupform, - }) - - -@user_is_authorized(Dojo_Group_Member, Permissions.Group_Manage_Members, "mid") -def edit_group_member(request, mid): - member = get_object_or_404(Dojo_Group_Member, pk=mid) - memberform = Edit_Group_MemberForm(instance=member) - - if request.method == "POST": - memberform = Edit_Group_MemberForm(request.POST, instance=member) - if memberform.is_valid(): - if not member.role.is_owner: - owners = Dojo_Group_Member.objects.filter(group=member.group, role__is_owner=True).exclude(id=member.id).count() - if owners < 1: - messages.add_message(request, - messages.WARNING, - f"There must be at least one owner for group {member.group.name}.", - extra_tags="alert-warning") - if is_title_in_breadcrumbs("View User"): - return HttpResponseRedirect(reverse("view_user", args=(member.user.id, ))) - return HttpResponseRedirect(reverse("view_group", args=(member.group.id, ))) - if member.role.is_owner and not user_has_permission(request.user, member.group, Permissions.Group_Add_Owner): - messages.add_message(request, - messages.WARNING, - "You are not permitted to make users owners.", - extra_tags="alert-warning") - else: - memberform.save() - messages.add_message(request, - messages.SUCCESS, - "Group member updated successfully", - extra_tags="alert-success") - if is_title_in_breadcrumbs("View User"): - return HttpResponseRedirect(reverse("view_user", args=(member.user.id, ))) - return HttpResponseRedirect(reverse("view_group", args=(member.group.id, ))) - - add_breadcrumb(title="Edit a Group Member", top_level=False, request=request) - return render(request, "dojo/edit_group_member.html", { - "memberid": mid, - "form": memberform, - }) - - -@user_is_authorized(Dojo_Group_Member, Permissions.Group_Member_Delete, "mid") -def delete_group_member(request, mid): - member = get_object_or_404(Dojo_Group_Member, pk=mid) - memberform = Delete_Group_MemberForm(instance=member) - - if request.method == "POST": - memberform = Delete_Group_MemberForm(request.POST, instance=member) - member = memberform.instance - if member.role.is_owner: - owners = Dojo_Group_Member.objects.filter(group=member.group, role__is_owner=True).count() - if owners <= 1: - messages.add_message(request, - messages.WARNING, - f"There must be at least one owner for group {member.group.name}.", - extra_tags="alert-warning") - if is_title_in_breadcrumbs("View User"): - return HttpResponseRedirect(reverse("view_user", args=(member.user.id, ))) - return HttpResponseRedirect(reverse("view_group", args=(member.group.id, ))) - - user = member.user - member.delete() - messages.add_message(request, - messages.SUCCESS, - "Group member deleted successfully.", - extra_tags="alert-success") - if is_title_in_breadcrumbs("View User"): - return HttpResponseRedirect(reverse("view_user", args=(member.user.id, ))) - if user == request.user: - return HttpResponseRedirect(reverse("groups")) - return HttpResponseRedirect(reverse("view_group", args=(member.group.id, ))) - - add_breadcrumb("Delete a group member", top_level=False, request=request) - return render(request, "dojo/delete_group_member.html", { - "memberid": mid, - "form": memberform, - }) - - -@user_passes_test(lambda u: u.is_superuser) -def add_product_group(request, gid): - group = get_object_or_404(Dojo_Group, id=gid) - group_form = Add_Product_Group_GroupForm(initial={"group": group.id}) - page_name = str(labels.ASSET_GROUPS_ADD_LABEL) - - if request.method == "POST": - group_form = Add_Product_Group_GroupForm(request.POST, initial={"group": group.id}) - if group_form.is_valid(): - if "products" in group_form.cleaned_data and len(group_form.cleaned_data["products"]) > 0: - for product in group_form.cleaned_data["products"]: - existing_groups = Product_Group.objects.filter(product=product, group=group) - if existing_groups.count() == 0: - product_group = Product_Group() - product_group.product = product - product_group.group = group - product_group.role = group_form.cleaned_data["role"] - product_group.save() - messages.add_message(request, - messages.SUCCESS, - labels.ASSET_GROUPS_ADD_SUCCESS_MESSAGE, - extra_tags="alert-success") - return HttpResponseRedirect(reverse("view_group", args=(gid, ))) - - add_breadcrumb(title=page_name, top_level=False, request=request) - return render(request, "dojo/new_product_group_group.html", { - "name": page_name, - "group": group, - "form": group_form, - }) - - -@user_passes_test(lambda u: u.is_superuser) -def add_product_type_group(request, gid): - group = get_object_or_404(Dojo_Group, id=gid) - group_form = Add_Product_Type_Group_GroupForm(initial={"group": group.id}) - page_name = str(labels.ORG_GROUPS_ADD_LABEL) - - if request.method == "POST": - group_form = Add_Product_Type_Group_GroupForm(request.POST, initial={"group": group.id}) - if group_form.is_valid(): - if "product_types" in group_form.cleaned_data and len(group_form.cleaned_data["product_types"]) > 0: - for product_type in group_form.cleaned_data["product_types"]: - existing_groups = Product_Type_Group.objects.filter(product_type=product_type, group=group) - if existing_groups.count() == 0: - product_type_group = Product_Type_Group() - product_type_group.product_type = product_type - product_type_group.group = group - product_type_group.role = group_form.cleaned_data["role"] - product_type_group.save() - messages.add_message(request, - messages.SUCCESS, - labels.ORG_GROUPS_ADD_SUCCESS_MESSAGE, - extra_tags="alert-success") - return HttpResponseRedirect(reverse("view_group", args=(gid, ))) - - add_breadcrumb(title=page_name, top_level=False, request=request) - return render(request, "dojo/new_product_type_group_group.html", { - "name": page_name, - "group": group, - "form": group_form, - }) - - -@user_is_configuration_authorized("auth.change_permission") -def edit_permissions(request, gid): - group = get_object_or_404(Dojo_Group, id=gid) - if request.method == "POST": - form = ConfigurationPermissionsForm(request.POST, group=group) - if form.is_valid(): - form.save() - messages.add_message(request, - messages.SUCCESS, - "Permissions updated.", - extra_tags="alert-success") - return HttpResponseRedirect(reverse("view_group", args=(gid,))) diff --git a/dojo/home/views.py b/dojo/home/views.py index 953265233b7..ac3581c4331 100644 --- a/dojo/home/views.py +++ b/dojo/home/views.py @@ -9,7 +9,6 @@ from django.utils import timezone from dojo.authorization.authorization import user_has_configuration_permission -from dojo.authorization.roles_permissions import Permissions from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import get_authorized_findings from dojo.models import Answered_Survey @@ -21,8 +20,8 @@ def home(request: HttpRequest) -> HttpResponse: def dashboard(request: HttpRequest) -> HttpResponse: - engagements = get_authorized_engagements(Permissions.Engagement_View).distinct() - findings = get_authorized_findings(Permissions.Finding_View).distinct() + engagements = get_authorized_engagements("view").distinct() + findings = get_authorized_findings("view").distinct() findings = findings.filter(duplicate=False) diff --git a/dojo/importers/auto_create_context.py b/dojo/importers/auto_create_context.py index 916bbea056e..24a9e9c2244 100644 --- a/dojo/importers/auto_create_context.py +++ b/dojo/importers/auto_create_context.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta from typing import Any -from crum import get_current_user from django.db import transaction from django.http.request import QueryDict from django.utils import timezone @@ -10,13 +9,10 @@ from dojo.models import ( Engagement, Product, - Product_Member, Product_Type, - Product_Type_Member, - Role, Test, ) -from dojo.utils import get_last_object_or_none, get_object_or_none +from dojo.utils import get_current_user, get_last_object_or_none, get_object_or_none logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -231,20 +227,14 @@ def get_or_create_product_type( ) -> Product_Type: """ Fetches a product type by name if one already exists. If not, - a new product type will be created with the current user being - added as product type member + a new product type will be created. RBAC ownership of the new row + is bootstrapped by Pro's post_save signal on Product_Type. """ # Look for an existing object if product_type := self.get_target_product_type_if_exists(product_type_name=product_type_name): return product_type with transaction.atomic(): - product_type, created = Product_Type.objects.select_for_update().get_or_create(name=product_type_name) - if created: - Product_Type_Member.objects.create( - user=get_current_user(), - product_type=product_type, - role=Role.objects.get(is_owner=True), - ) + product_type, _created = Product_Type.objects.select_for_update().get_or_create(name=product_type_name) return product_type def get_or_create_product( @@ -257,8 +247,8 @@ def get_or_create_product( ) -> Product: """ Fetches a product by name if it exists. When `auto_create_context` is - enabled the product will be created with the current user being added - as product member + enabled the product will be created. RBAC ownership of the new row is + bootstrapped by Pro's post_save signal on Product. """ # try to find the product (within the provided product_type) if product := self.get_target_product_if_exists(product_name, product_type_name): @@ -271,13 +261,7 @@ def get_or_create_product( product_type = self.get_or_create_product_type(product_type_name=product_type_name) # Create the product with transaction.atomic(): - product, created = Product.objects.select_for_update().get_or_create(name=product_name, prod_type=product_type, description=product_name) - if created: - Product_Member.objects.create( - user=get_current_user(), - product=product, - role=Role.objects.get(is_owner=True), - ) + product, _created = Product.objects.select_for_update().get_or_create(name=product_name, prod_type=product_type, description=product_name) return product diff --git a/dojo/jira/api/views.py b/dojo/jira/api/views.py index 71fa5ee6393..a196a401a39 100644 --- a/dojo/jira/api/views.py +++ b/dojo/jira/api/views.py @@ -2,9 +2,8 @@ from drf_spectacular.utils import extend_schema_view from rest_framework.permissions import IsAuthenticated -from dojo.api_v2 import permissions from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, schema_with_prefetch -from dojo.authorization.roles_permissions import Permissions +from dojo.authorization import api_permissions as permissions from dojo.jira.api.serializers import ( JIRAInstanceSerializer, JIRAIssueSerializer, @@ -58,7 +57,7 @@ class JiraIssuesViewSet( ) def get_queryset(self): - return get_authorized_jira_issues(Permissions.Product_View) + return get_authorized_jira_issues("view") # Authorization: object-based @@ -88,4 +87,4 @@ class JiraProjectViewSet( ) def get_queryset(self): - return get_authorized_jira_projects(Permissions.Product_View) + return get_authorized_jira_projects("view") diff --git a/dojo/jira/queries.py b/dojo/jira/queries.py index 5ce281d2296..418e1fae69d 100644 --- a/dojo/jira/queries.py +++ b/dojo/jira/queries.py @@ -1,115 +1,25 @@ -from crum import get_current_user -from django.db.models import Q, Subquery +try: + from dojo.authorization.query_filters import get_auth_filter +except ImportError: + def get_auth_filter(key): return None -from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission -from dojo.models import JIRA_Issue, JIRA_Project, Product_Group, Product_Member, Product_Type_Group, Product_Type_Member +from dojo.models import JIRA_Issue, JIRA_Project from dojo.request_cache import cache_for_request # Cached: all parameters are hashable, no dynamic queryset filtering @cache_for_request def get_authorized_jira_projects(permission, user=None): - - if user is None: - user = get_current_user() - - if user is None: - return JIRA_Project.objects.none() - - jira_projects = JIRA_Project.objects.all().order_by("id") - - if user.is_superuser: - return jira_projects - - if user_has_global_permission(user, permission): - return jira_projects - - roles = get_roles_for_permission(permission) - - # Get authorized product/product_type IDs via subqueries - authorized_product_type_roles = Product_Type_Member.objects.filter( - user=user, role__in=roles, - ).values("product_type_id") - - authorized_product_roles = Product_Member.objects.filter( - user=user, role__in=roles, - ).values("product_id") - - authorized_product_type_groups = Product_Type_Group.objects.filter( - group__users=user, role__in=roles, - ).values("product_type_id") - - authorized_product_groups = Product_Group.objects.filter( - group__users=user, role__in=roles, - ).values("product_id") - - # Filter using IN with Subquery - no annotations needed - # JIRA projects can be attached via engagement or product path - return jira_projects.filter( - # Engagement path - Q(engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles)) - | Q(engagement__product_id__in=Subquery(authorized_product_roles)) - | Q(engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups)) - | Q(engagement__product_id__in=Subquery(authorized_product_groups)) - # Product path - | Q(product__prod_type_id__in=Subquery(authorized_product_type_roles)) - | Q(product_id__in=Subquery(authorized_product_roles)) - | Q(product__prod_type_id__in=Subquery(authorized_product_type_groups)) - | Q(product_id__in=Subquery(authorized_product_groups)), - ) + impl = get_auth_filter("jira_link.get_authorized_jira_projects") + if impl: + return impl(permission, user=user) + return JIRA_Project.objects.all().order_by("id") # Cached: all parameters are hashable, no dynamic queryset filtering @cache_for_request def get_authorized_jira_issues(permission): - user = get_current_user() - - if user is None: - return JIRA_Issue.objects.none() - - jira_issues = JIRA_Issue.objects.all().order_by("id") - - if user.is_superuser: - return jira_issues - - if user_has_global_permission(user, permission): - return jira_issues - - roles = get_roles_for_permission(permission) - - # Get authorized product/product_type IDs via subqueries - authorized_product_type_roles = Product_Type_Member.objects.filter( - user=user, role__in=roles, - ).values("product_type_id") - - authorized_product_roles = Product_Member.objects.filter( - user=user, role__in=roles, - ).values("product_id") - - authorized_product_type_groups = Product_Type_Group.objects.filter( - group__users=user, role__in=roles, - ).values("product_type_id") - - authorized_product_groups = Product_Group.objects.filter( - group__users=user, role__in=roles, - ).values("product_id") - - # Filter using IN with Subquery - no annotations needed - # JIRA issues can be attached via engagement, finding_group, or finding path - return jira_issues.filter( - # Engagement path - Q(engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles)) - | Q(engagement__product_id__in=Subquery(authorized_product_roles)) - | Q(engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups)) - | Q(engagement__product_id__in=Subquery(authorized_product_groups)) - # Finding group path - | Q(finding_group__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles)) - | Q(finding_group__test__engagement__product_id__in=Subquery(authorized_product_roles)) - | Q(finding_group__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups)) - | Q(finding_group__test__engagement__product_id__in=Subquery(authorized_product_groups)) - # Finding path - | Q(finding__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_roles)) - | Q(finding__test__engagement__product_id__in=Subquery(authorized_product_roles)) - | Q(finding__test__engagement__product__prod_type_id__in=Subquery(authorized_product_type_groups)) - | Q(finding__test__engagement__product_id__in=Subquery(authorized_product_groups)), - ) + impl = get_auth_filter("jira_link.get_authorized_jira_issues") + if impl: + return impl(permission) + return JIRA_Issue.objects.all().order_by("id") diff --git a/dojo/location/api/endpoint_compat.py b/dojo/location/api/endpoint_compat.py index 964da4c0d4d..6c32929aaea 100644 --- a/dojo/location/api/endpoint_compat.py +++ b/dojo/location/api/endpoint_compat.py @@ -25,11 +25,10 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from dojo.api_v2 import serializers -from dojo.api_v2.permissions import check_object_permission from dojo.api_v2.prefetch import PrefetchListMixin, PrefetchRetrieveMixin from dojo.api_v2.serializers import TagListSerializerField from dojo.api_v2.views import report_generate -from dojo.authorization.roles_permissions import Permissions +from dojo.authorization.api_permissions import check_object_permission from dojo.filters import CharFieldFilterANDExpression, CharFieldInFilter, OrderingFilter from dojo.location.models import LocationFindingReference, LocationProductReference from dojo.location.queries import get_authorized_location_finding_reference, get_authorized_location_product_reference @@ -64,9 +63,9 @@ def has_object_permission(self, request, view, obj): return check_object_permission( request, obj, - Permissions.Location_View, - Permissions.Location_Edit, - Permissions.Location_Delete, + "view", + "edit", + "delete", ) @@ -156,7 +155,7 @@ def get_queryset(self): ), group_field="location", ) - return get_authorized_location_product_reference(Permissions.Location_View).filter( + return get_authorized_location_product_reference("view").filter( location__location_type=URL.LOCATION_TYPE, ).annotate( active_finding_count=Coalesce(active_finding_subquery, Value(0)), @@ -316,4 +315,4 @@ class V3EndpointStatusCompatibleViewSet(PrefetchListMixin, PrefetchRetrieveMixin def get_queryset(self): """Get authorized URLs using Endpoint authorization logic.""" - return get_authorized_location_finding_reference(Permissions.Location_View).filter(location__location_type=URL.LOCATION_TYPE).distinct() + return get_authorized_location_finding_reference("view").filter(location__location_type=URL.LOCATION_TYPE).distinct() diff --git a/dojo/location/api/permissions.py b/dojo/location/api/permissions.py deleted file mode 100644 index 0dd41b33aa4..00000000000 --- a/dojo/location/api/permissions.py +++ /dev/null @@ -1,46 +0,0 @@ -from rest_framework.permissions import BasePermission - -from dojo.api_v2.permissions import check_object_permission, check_post_permission -from dojo.authorization.roles_permissions import Permissions -from dojo.models import ( - Finding, - Product, -) - - -class LocationFindingReferencePermission(BasePermission): - def has_permission(self, request, view): - return check_post_permission( - request, - Finding, - "finding", - Permissions.Finding_Edit, - ) - - def has_object_permission(self, request, view, obj): - return check_object_permission( - request, - obj.finding, - Permissions.Finding_View, - Permissions.Finding_Edit, - Permissions.Finding_Edit, - ) - - -class LocationProductReferencePermission(BasePermission): - def has_permission(self, request, view): - return check_post_permission( - request, - Product, - "product", - Permissions.Product_Edit, - ) - - def has_object_permission(self, request, view, obj): - return check_object_permission( - request, - obj.product, - Permissions.Product_View, - Permissions.Product_Edit, - Permissions.Product_Edit, - ) diff --git a/dojo/location/api/views.py b/dojo/location/api/views.py index 9eb276bc6b3..6d7497dc238 100644 --- a/dojo/location/api/views.py +++ b/dojo/location/api/views.py @@ -3,18 +3,17 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.viewsets import ReadOnlyModelViewSet -from dojo.api_v2.permissions import IsSuperUser from dojo.api_v2.views import PrefetchDojoModelViewSet -from dojo.authorization.roles_permissions import Permissions +from dojo.authorization.api_permissions import ( + IsSuperUser, + LocationFindingReferencePermission, + LocationProductReferencePermission, +) from dojo.location.api.filters import ( LocationFilter, LocationFindingReferenceFilter, LocationProductReferenceFilter, ) -from dojo.location.api.permissions import ( - LocationFindingReferencePermission, - LocationProductReferencePermission, -) from dojo.location.api.serializers import ( LocationFindingReferenceSerializer, LocationProductReferenceSerializer, @@ -61,7 +60,7 @@ class LocationFindingReferenceViewSet(PrefetchDojoModelViewSet): def get_queryset(self) -> QuerySet[LocationFindingReference]: """Return the queryset of LocationFindingReferences.""" - return get_authorized_location_finding_reference(Permissions.Location_View) + return get_authorized_location_finding_reference("view") class LocationProductReferenceViewSet(PrefetchDojoModelViewSet): @@ -79,4 +78,4 @@ class LocationProductReferenceViewSet(PrefetchDojoModelViewSet): def get_queryset(self) -> QuerySet[LocationProductReference]: """Return the queryset of LocationProductReferences.""" - return get_authorized_location_product_reference(Permissions.Location_View) + return get_authorized_location_product_reference("view") diff --git a/dojo/location/queries.py b/dojo/location/queries.py index fae2a5b9ad8..63abeefce8d 100644 --- a/dojo/location/queries.py +++ b/dojo/location/queries.py @@ -1,11 +1,9 @@ import logging -from crum import get_current_user from django.db.models import ( Case, CharField, Count, - Exists, F, IntegerField, OuterRef, @@ -17,15 +15,15 @@ ) from django.db.models.functions import Coalesce -from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission +try: + from dojo.authorization.query_filters import get_auth_filter +except ImportError: + def get_auth_filter(key): return None + from dojo.location.models import Location, LocationFindingReference, LocationProductReference from dojo.location.status import FindingLocationStatus, ProductLocationStatus from dojo.models import ( Finding, - Product_Group, - Product_Member, - Product_Type_Group, - Product_Type_Member, ) from dojo.query_utils import build_count_subquery @@ -33,132 +31,24 @@ def get_authorized_locations(permission, queryset=None, user=None): - - if user is None: - user = get_current_user() - - if user is None: - return Location.objects.none() - - locations = Location.objects.all().order_by("id") if queryset is None else queryset - - if user.is_superuser: - return locations - - if user_has_global_permission(user, permission): - return locations - - roles = get_roles_for_permission(permission) - authorized_product_type_roles = Product_Type_Member.objects.filter( - product_type=OuterRef("products__product__prod_type_id"), - user=user, - role__in=roles) - authorized_product_roles = Product_Member.objects.filter( - product=OuterRef("products__product_id"), - user=user, - role__in=roles) - authorized_product_type_groups = Product_Type_Group.objects.filter( - product_type=OuterRef("products__product__prod_type_id"), - group__users=user, - role__in=roles) - authorized_product_groups = Product_Group.objects.filter( - product=OuterRef("products__product_id"), - group__users=user, - role__in=roles) - locations = locations.annotate( - product__prod_type__member=Exists(authorized_product_type_roles), - product__member=Exists(authorized_product_roles), - product__prod_type__authorized_group=Exists(authorized_product_type_groups), - product__authorized_group=Exists(authorized_product_groups)) - return locations.filter( - Q(product__prod_type__member=True) | Q(product__member=True) - | Q(product__prod_type__authorized_group=True) | Q(product__authorized_group=True)) + impl = get_auth_filter("location.get_authorized_locations") + if impl: + return impl(permission, queryset=queryset, user=user) + return Location.objects.all().order_by("id") if queryset is None else queryset def get_authorized_location_finding_reference(permission, queryset=None, user=None): - - if user is None: - user = get_current_user() - - if user is None: - return LocationFindingReference.objects.none() - - location_finding_reference = LocationFindingReference.objects.all().order_by("id") if queryset is None else queryset - - if user.is_superuser: - return location_finding_reference - - if user_has_global_permission(user, permission): - return location_finding_reference - - roles = get_roles_for_permission(permission) - authorized_product_type_roles = Product_Type_Member.objects.filter( - product_type=OuterRef("location__products__product__prod_type_id"), - user=user, - role__in=roles) - authorized_product_roles = Product_Member.objects.filter( - product=OuterRef("location__products__product_id"), - user=user, - role__in=roles) - authorized_product_type_groups = Product_Type_Group.objects.filter( - product_type=OuterRef("location__products__product__prod_type_id"), - group__users=user, - role__in=roles) - authorized_product_groups = Product_Group.objects.filter( - product=OuterRef("location__products__product_id"), - group__users=user, - role__in=roles) - location_finding_reference = location_finding_reference.annotate( - location__product__prod_type__member=Exists(authorized_product_type_roles), - location__product__member=Exists(authorized_product_roles), - location__product__prod_type__authorized_group=Exists(authorized_product_type_groups), - location__product__authorized_group=Exists(authorized_product_groups)) - return location_finding_reference.filter( - Q(location__product__prod_type__member=True) | Q(location__product__member=True) - | Q(location__product__prod_type__authorized_group=True) | Q(location__product__authorized_group=True)) + impl = get_auth_filter("location.get_authorized_location_finding_reference") + if impl: + return impl(permission, queryset=queryset, user=user) + return LocationFindingReference.objects.all().order_by("id") if queryset is None else queryset def get_authorized_location_product_reference(permission, queryset=None, user=None): - - if user is None: - user = get_current_user() - - if user is None: - return LocationProductReference.objects.none() - - location_product_reference = LocationProductReference.objects.all().order_by("id") if queryset is None else queryset - - if user.is_superuser: - return location_product_reference - - if user_has_global_permission(user, permission): - return location_product_reference - - roles = get_roles_for_permission(permission) - authorized_product_type_roles = Product_Type_Member.objects.filter( - product_type=OuterRef("product__prod_type_id"), - user=user, - role__in=roles) - authorized_product_roles = Product_Member.objects.filter( - product=OuterRef("product_id"), - user=user, - role__in=roles) - authorized_product_type_groups = Product_Type_Group.objects.filter( - product_type=OuterRef("product__prod_type_id"), - group__users=user, - role__in=roles) - authorized_product_groups = Product_Group.objects.filter( - product=OuterRef("product_id"), - group__users=user, - role__in=roles) - location_product_reference = location_product_reference.annotate( - location__product__prod_type__member=Exists(authorized_product_type_roles), - location__product__member=Exists(authorized_product_roles), - location__product__prod_type__authorized_group=Exists(authorized_product_type_groups), - location__product__authorized_group=Exists(authorized_product_groups)) - return location_product_reference.filter( - Q(location__product__prod_type__member=True) | Q(location__product__member=True) - | Q(location__product__prod_type__authorized_group=True) | Q(location__product__authorized_group=True)) + impl = get_auth_filter("location.get_authorized_location_product_reference") + if impl: + return impl(permission, queryset=queryset, user=user) + return LocationProductReference.objects.all().order_by("id") if queryset is None else queryset def annotate_location_counts_and_status(locations): diff --git a/dojo/management/commands/migrate_staff_users.py b/dojo/management/commands/migrate_staff_users.py deleted file mode 100644 index c862c0dee35..00000000000 --- a/dojo/management/commands/migrate_staff_users.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging -import sys - -from django.contrib.auth.models import Permission -from django.core.management.base import BaseCommand - -from dojo.models import Dojo_Group, Dojo_Group_Member, Dojo_User, Role - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - - """ - This management command creates a group for staff users with all configuration - permissions staff users had in previous releases. - """ - - help = "Usage: manage.py migrate_staff_users" - - def handle(self, *args, **options): - - # If group already exists, then the migration has been running before - group_name = "Staff users" - groups = Dojo_Group.objects.filter(name=group_name).count() - if groups > 0: - sys.exit(f"Group {group_name} already exists, migration aborted") - - # The superuser with the lowest id will be set as the owner of the group - users = Dojo_User.objects.filter(is_superuser=True).order_by("id") - if len(users) == 0: - sys.exit("No superuser found, migration aborted") - user = users[0] - - group = Dojo_Group(name=group_name, description="Migrated staff users") - group.save() - - owner_role = Role.objects.get(is_owner=True) - - owner = Dojo_Group_Member( - user=user, - group=group, - role=owner_role, - ) - owner.save() - - # All staff users are made to members of the group - reader_role = Role.objects.get(name="Reader") - staff_users = Dojo_User.objects.filter(is_staff=True) - for staff_user in staff_users: - if staff_user != owner.user: - member = Dojo_Group_Member( - user=staff_user, - group=group, - role=reader_role, - ) - member.save() - - permissions_list = Permission.objects.all() - permissions = {} - for permission in permissions_list: - permissions[permission.codename] = permission - - # Set the same configuration permissions, staff users had in previous releases - auth_group = group.auth_group - if not auth_group: - sys.exit("Group has no auth_group, migration aborted") - - auth_group.permissions.add(permissions["view_group"]) - auth_group.permissions.add(permissions["add_group"]) - auth_group.permissions.add(permissions["view_development_environment"]) - auth_group.permissions.add(permissions["add_development_environment"]) - auth_group.permissions.add(permissions["change_development_environment"]) - auth_group.permissions.add(permissions["delete_development_environment"]) - auth_group.permissions.add(permissions["view_finding_template"]) - auth_group.permissions.add(permissions["add_finding_template"]) - auth_group.permissions.add(permissions["change_finding_template"]) - auth_group.permissions.add(permissions["delete_finding_template"]) - auth_group.permissions.add(permissions["view_engagement_survey"]) - auth_group.permissions.add(permissions["add_engagement_survey"]) - auth_group.permissions.add(permissions["change_engagement_survey"]) - auth_group.permissions.add(permissions["delete_engagement_survey"]) - auth_group.permissions.add(permissions["view_question"]) - auth_group.permissions.add(permissions["add_question"]) - auth_group.permissions.add(permissions["change_question"]) - auth_group.permissions.add(permissions["delete_question"]) - auth_group.permissions.add(permissions["view_test_type"]) - auth_group.permissions.add(permissions["add_test_type"]) - auth_group.permissions.add(permissions["change_test_type"]) - auth_group.permissions.add(permissions["delete_test_type"]) - auth_group.permissions.add(permissions["view_user"]) - auth_group.permissions.add(permissions["add_product_type"]) - - logger.info(f"Migrated {len(staff_users)} staff users") diff --git a/dojo/metrics/utils.py b/dojo/metrics/utils.py index 444c7aee04d..f72e5d71063 100644 --- a/dojo/metrics/utils.py +++ b/dojo/metrics/utils.py @@ -16,7 +16,6 @@ from django.utils import timezone from django.utils.translation import gettext as _ -from dojo.authorization.roles_permissions import Permissions from dojo.endpoint.queries import get_authorized_endpoint_status_for_queryset from dojo.filters import ( MetricsEndpointFilter, @@ -47,7 +46,7 @@ def finding_queries( ) -> dict[str, Any]: # Get the initial list of findings the user is authorized to see all_authorized_findings: QuerySet[Finding] = get_authorized_findings( - Permissions.Finding_View, + "view", user=request.user, ).select_related( "reporter", @@ -186,7 +185,7 @@ def endpoint_queries( "finding__reporter", ) - endpoints_query = get_authorized_endpoint_status_for_queryset(Permissions.Location_View, endpoints_query, request.user) + endpoints_query = get_authorized_endpoint_status_for_queryset("view", endpoints_query, request.user) filter_string_matching = get_system_setting("filter_string_matching", False) filter_class = MetricsEndpointFilterWithoutObjectLookups if filter_string_matching else MetricsEndpointFilter endpoints = filter_class(request.GET, queryset=endpoints_query) @@ -232,8 +231,8 @@ def endpoint_queries( "finding__test__engagement__product", ) - endpoints_closed = get_authorized_endpoint_status_for_queryset(Permissions.Location_View, endpoints_closed, request.user) - accepted_endpoints = get_authorized_endpoint_status_for_queryset(Permissions.Location_View, accepted_endpoints, request.user) + endpoints_closed = get_authorized_endpoint_status_for_queryset("view", endpoints_closed, request.user) + accepted_endpoints = get_authorized_endpoint_status_for_queryset("view", accepted_endpoints, request.user) accepted_endpoints_counts = severity_count(accepted_endpoints, "aggregate", "finding__severity") weeks_between, months_between = period_deltas(start_date, end_date) diff --git a/dojo/metrics/views.py b/dojo/metrics/views.py index 9f269776b29..28141bc95de 100644 --- a/dojo/metrics/views.py +++ b/dojo/metrics/views.py @@ -19,7 +19,6 @@ from django.views.decorators.vary import vary_on_cookie from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.authorization.roles_permissions import Permissions from dojo.filters import UserFilter from dojo.forms import ProductTagCountsForm, ProductTypeCountsForm, SimpleMetricsForm from dojo.labels import get_labels @@ -63,7 +62,7 @@ def critical_product_metrics(request, mtype): template = "dojo/metrics.html" page_name = str(labels.ASSET_METRICS_CRITICAL_LABEL) - critical_products = get_authorized_product_types(Permissions.Product_Type_View) + critical_products = get_authorized_product_types("view") critical_products = critical_products.filter(critical_product=True) add_breadcrumb(title=page_name, top_level=not len(request.GET), request=request) return render(request, template, { @@ -92,7 +91,7 @@ def metrics(request, mtype): elif "test__engagement__product__prod_type" in request.GET: prod_type = Product_Type.objects.filter(id__in=request.GET.getlist("test__engagement__product__prod_type", [])) else: - prod_type = get_authorized_product_types(Permissions.Product_Type_View) + prod_type = get_authorized_product_types("view") # legacy code calls has 'prod_type' as 'related_name' for product.... so weird looking prefetch prod_type = prod_type.prefetch_related("prod_type") @@ -179,7 +178,7 @@ def simple_metrics(request): # for each product type find each product with open findings and # count the S0, S1, S2 and S3 # legacy code calls has 'prod_type' as 'related_name' for product.... so weird looking prefetch - product_types = get_authorized_product_types(Permissions.Product_Type_View) + product_types = get_authorized_product_types("view") product_types = product_types.prefetch_related("prod_type") for pt in product_types: total_critical = [] @@ -277,7 +276,7 @@ def product_type_counts(request): form = ProductTypeCountsForm(request.GET) if form.is_valid(): pt = form.cleaned_data["product_type"] - user_has_permission_or_403(request.user, pt, Permissions.Product_Type_View) + user_has_permission_or_403(request.user, pt, "view") month = int(form.cleaned_data["month"]) year = int(form.cleaned_data["year"]) first_of_month = first_of_month.replace(month=month, year=year) @@ -479,7 +478,7 @@ def product_tag_counts(request): if request.method == "GET" and "month" in request.GET and "year" in request.GET and "product_tag" in request.GET: form = ProductTagCountsForm(request.GET) if form.is_valid(): - prods = get_authorized_products(Permissions.Product_View) + prods = get_authorized_products("view") pt = form.cleaned_data["product_tag"] month = int(form.cleaned_data["month"]) @@ -778,7 +777,7 @@ def view_engineer(request, eid): # -------------- # Product tables - products = list(get_authorized_products(Permissions.Product_Type_View).only("id", "name")) + products = list(get_authorized_products("view").only("id", "name")) update, total_update = _product_stats(products) # ---------------------------------- diff --git a/dojo/models.py b/dojo/models.py index 63ca1cf31ba..420d85909eb 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -22,7 +22,6 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator, validate_ipv46_address @@ -274,38 +273,11 @@ class UserContactInfo(models.Model): slack_user_id = models.CharField(blank=True, null=True, max_length=25) block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion.")) force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) + ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) -class Dojo_Group(models.Model): - AZURE = "AzureAD" - REMOTE = "Remote" - SOCIAL_CHOICES = ( - (AZURE, _("AzureAD")), - (REMOTE, _("Remote")), - ) - name = models.CharField(max_length=255, unique=True) - description = models.CharField(max_length=4000, null=True, blank=True) - users = models.ManyToManyField(Dojo_User, through="Dojo_Group_Member", related_name="users", blank=True) - auth_group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.CASCADE) - social_provider = models.CharField(max_length=10, choices=SOCIAL_CHOICES, blank=True, null=True, help_text=_("Group imported from a social provider."), verbose_name=_("Social Authentication Provider")) - - def __str__(self): - return self.name - - -class Role(models.Model): - name = models.CharField(max_length=255, unique=True) - is_owner = models.BooleanField(default=False) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name - - class System_Settings(models.Model): enable_deduplication = models.BooleanField( default=False, @@ -601,23 +573,6 @@ class System_Settings(models.Model): blank=False, verbose_name=_("Enable CVSS4 Display"), help_text=_("With this setting turned off, CVSS4 fields will be hidden in the user interface.")) - default_group = models.ForeignKey( - Dojo_Group, - null=True, - blank=True, - help_text=_("New users will be assigned to this group."), - on_delete=models.RESTRICT) - default_group_role = models.ForeignKey( - Role, - null=True, - blank=True, - help_text=_("New users will be assigned to their default group with this role."), - on_delete=models.RESTRICT) - default_group_email_pattern = models.CharField( - max_length=200, - default="", - blank=True, - help_text=_("New users will only be assigned to the default group, when their email address matches this regex pattern. This is optional condition.")) minimum_password_length = models.IntegerField( default=9, verbose_name=_("Minimum password length"), @@ -692,18 +647,6 @@ def get_current_datetime(): return timezone.now() -class Dojo_Group_Member(models.Model): - group = models.ForeignKey(Dojo_Group, on_delete=models.CASCADE) - user = models.ForeignKey(Dojo_User, on_delete=models.CASCADE) - role = models.ForeignKey(Role, on_delete=models.CASCADE, help_text=_("This role determines the permissions of the user to manage the group."), verbose_name=_("Group role")) - - -class Global_Role(models.Model): - user = models.OneToOneField(Dojo_User, null=True, blank=True, on_delete=models.CASCADE) - group = models.OneToOneField(Dojo_Group, null=True, blank=True, on_delete=models.CASCADE) - role = models.ForeignKey(Role, on_delete=models.CASCADE, null=True, blank=True, help_text=_("The global role will be applied to all product types and products."), verbose_name=_("Global role")) - - class Contact(models.Model): name = models.CharField(max_length=100) email = models.EmailField() @@ -847,8 +790,7 @@ class Product_Type(BaseModel): description = models.CharField(max_length=4000, null=True, blank=True) critical_product = models.BooleanField(default=False) key_product = models.BooleanField(default=False) - members = models.ManyToManyField(Dojo_User, through="Product_Type_Member", related_name="prod_type_members", blank=True) - authorization_groups = models.ManyToManyField(Dojo_Group, through="Product_Type_Group", related_name="product_type_groups", blank=True) + authorized_users = models.ManyToManyField(Dojo_User, related_name="authorized_product_types", blank=True) class Meta: ordering = ("name",) @@ -1192,8 +1134,7 @@ class Product(BaseModel): default=1, on_delete=models.RESTRICT) tid = models.IntegerField(default=0, editable=False) - members = models.ManyToManyField(Dojo_User, through="Product_Member", related_name="product_members", blank=True) - authorization_groups = models.ManyToManyField(Dojo_Group, through="Product_Group", related_name="product_groups", blank=True) + authorized_users = models.ManyToManyField(Dojo_User, related_name="authorized_products", blank=True) prod_numeric_grade = models.IntegerField(null=True, blank=True) # Metadata @@ -1372,30 +1313,6 @@ def violates_sla(self): return findings.count() > 0 -class Product_Member(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - user = models.ForeignKey(Dojo_User, on_delete=models.CASCADE) - role = models.ForeignKey(Role, on_delete=models.CASCADE) - - -class Product_Group(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - group = models.ForeignKey(Dojo_Group, on_delete=models.CASCADE) - role = models.ForeignKey(Role, on_delete=models.CASCADE) - - -class Product_Type_Member(models.Model): - product_type = models.ForeignKey(Product_Type, on_delete=models.CASCADE) - user = models.ForeignKey(Dojo_User, on_delete=models.CASCADE) - role = models.ForeignKey(Role, on_delete=models.CASCADE) - - -class Product_Type_Group(models.Model): - product_type = models.ForeignKey(Product_Type, on_delete=models.CASCADE) - group = models.ForeignKey(Dojo_Group, on_delete=models.CASCADE) - role = models.ForeignKey(Role, on_delete=models.CASCADE) - - class Tool_Type(models.Model): name = models.CharField(max_length=200) description = models.CharField(max_length=2000, null=True, blank=True) @@ -4562,6 +4479,17 @@ def __str__(self): admin.site.register(SLA_Configuration) admin.site.register(CWE) admin.site.register(Regulation) +from dojo.authorization.models import ( # noqa: E402 + Dojo_Group, + Dojo_Group_Member, + Global_Role, + Product_Group, + Product_Member, + Product_Type_Group, + Product_Type_Member, + Role, +) + admin.site.register(Global_Role) admin.site.register(Role) admin.site.register(Dojo_Group) diff --git a/dojo/note_type/views.py b/dojo/note_type/views.py index c02c92cb82f..65c908c740e 100644 --- a/dojo/note_type/views.py +++ b/dojo/note_type/views.py @@ -5,7 +5,6 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse -from dojo.authorization.authorization_decorators import user_is_configuration_authorized from dojo.filters import NoteTypesFilter from dojo.forms import DisableOrEnableNoteTypeForm, EditNoteTypeForm, NoteTypeForm from dojo.models import Note_Type @@ -14,7 +13,6 @@ logger = logging.getLogger(__name__) -@user_is_configuration_authorized("dojo.view_note_type") def note_type(request): initial_queryset = Note_Type.objects.all().order_by("name") name_words = initial_queryset.values_list("name", flat=True) @@ -30,7 +28,6 @@ def note_type(request): "name_words": name_words}) -@user_is_configuration_authorized("dojo.change_note_type") def edit_note_type(request, ntid): nt = get_object_or_404(Note_Type, pk=ntid) is_single = nt.is_single @@ -56,7 +53,6 @@ def edit_note_type(request, ntid): "nt": nt}) -@user_is_configuration_authorized("dojo.change_note_type") def disable_note_type(request, ntid): nt = get_object_or_404(Note_Type, pk=ntid) nt_form = DisableOrEnableNoteTypeForm(instance=nt) @@ -81,7 +77,6 @@ def disable_note_type(request, ntid): "nt": nt}) -@user_is_configuration_authorized("dojo.change_note_type") def enable_note_type(request, ntid): nt = get_object_or_404(Note_Type, pk=ntid) nt_form = DisableOrEnableNoteTypeForm(instance=nt) @@ -105,7 +100,6 @@ def enable_note_type(request, ntid): "nt": nt}) -@user_is_configuration_authorized("dojo.add_note_type") def add_note_type(request): form = NoteTypeForm() if request.method == "POST": diff --git a/dojo/notes/views.py b/dojo/notes/views.py index 7d27649afd6..66c4d0aecda 100644 --- a/dojo/notes/views.py +++ b/dojo/notes/views.py @@ -12,7 +12,6 @@ from django.utils.translation import gettext as _ from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.authorization.roles_permissions import Permissions from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import get_authorized_findings @@ -33,13 +32,13 @@ def _get_page_details(request: HttpRequest, note_id: int, page: SUPPORTED_PAGES raise PermissionDenied # Get the real object based on page type if page == "engagement": - obj = get_authorized_engagements(Permissions.Engagement_View).filter(id=objid).first() + obj = get_authorized_engagements("view").filter(id=objid).first() reverse_url = "view_engagement" elif page == "test": - obj = get_authorized_tests(Permissions.Test_View).filter(id=objid).first() + obj = get_authorized_tests("view").filter(id=objid).first() reverse_url = "view_test" elif page == "finding": - obj = get_authorized_findings(Permissions.Finding_View).filter(id=objid).first() + obj = get_authorized_findings("view").filter(id=objid).first() reverse_url = "view_finding" else: # If we get here, something is wrong, so let's just raise PermissionDenied @@ -59,7 +58,7 @@ def delete_note(request: HttpRequest, note_id: int, page: SUPPORTED_PAGES, objid form = DeleteNoteForm(request.POST, instance=note) if str(request.user) != note.author.username: - user_has_permission_or_403(request.user, obj, Permissions.Note_Delete) + user_has_permission_or_403(request.user, obj, "delete") if form.is_valid(): note.delete() @@ -80,7 +79,7 @@ def edit_note(request: HttpRequest, note_id: int, page: SUPPORTED_PAGES, objid: note, obj, object_id, reverse_url = _get_page_details(request, note_id, page, objid) if str(request.user) != note.author.username: - user_has_permission_or_403(request.user, obj, Permissions.Note_Edit) + user_has_permission_or_403(request.user, obj, "edit") note_type_activation = Note_Type.objects.filter(is_active=True).count() if note_type_activation: @@ -139,7 +138,7 @@ def note_history(request: HttpRequest, note_id: int, page: SUPPORTED_PAGES, obji note, obj, object_id, reverse_url = _get_page_details(request, note_id, page, objid) if str(request.user) != note.author.username: - user_has_permission_or_403(request.user, obj, Permissions.Note_View_History) + user_has_permission_or_403(request.user, obj, "view") history = note.history.all() diff --git a/dojo/notifications/helper.py b/dojo/notifications/helper.py index 1de664c4a6d..0563912d203 100644 --- a/dojo/notifications/helper.py +++ b/dojo/notifications/helper.py @@ -17,7 +17,6 @@ from django.utils.translation import gettext as _ from dojo import __version__ as dd_version -from dojo.authorization.roles_permissions import Permissions from dojo.decorators import we_want_async from dojo.labels import get_labels from dojo.models import ( @@ -608,8 +607,13 @@ def send_alert_notification( icon=icon[:25], source=source, ) - # relative urls will fail validation - alert.clean_fields(exclude=["url"]) + # ``url`` skips validation (relative URLs are valid here but + # URLField.validate rejects them). ``user_id`` skips the FK + # existence probe — the user was just fetched from our own + # DB by the caller, so the ``SELECT 1 FROM auth_user WHERE id=N + # LIMIT 1`` round-trip every ForeignKey.validate would issue + # is pure overhead at fan-out time. + alert.clean_fields(exclude=["url", "user_id"]) alert.save() except Exception as exception: logger.exception("Unable to create Alert Notification") @@ -742,13 +746,13 @@ def _get_user_to_send_notifications_to( users = get_authorized_users_for_product_and_product_type( users, self.product, - Permissions.Product_View, + "view", ) elif self.product_type is not None: users = get_authorized_users_for_product_type( users, self.product_type, - Permissions.Product_Type_View, + "view", ) else: # nor product_type nor product defined, we should not make noise and send only notifications to admins diff --git a/dojo/notifications/templates/notifications/add_notification_webhook.html b/dojo/notifications/templates/notifications/add_notification_webhook.html index 12056373af4..f784e994fe3 100644 --- a/dojo/notifications/templates/notifications/add_notification_webhook.html +++ b/dojo/notifications/templates/notifications/add_notification_webhook.html @@ -2,7 +2,7 @@ {% block content %} {{ block.super }}

Add a new Notification Webhook

- {% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/notifications/templates/notifications/alerts.html b/dojo/notifications/templates/notifications/alerts.html index 086bec4efbe..8950c828648 100644 --- a/dojo/notifications/templates/notifications/alerts.html +++ b/dojo/notifications/templates/notifications/alerts.html @@ -55,7 +55,6 @@ {% endblock %} {% block postscript %} {{ block.super }} - - + - - - - - - - + + + - - - + - - - - + - - - - - - - - - - - + + - - + + - - + - + @@ -86,578 +67,445 @@ {% block add_css %} {% endblock %} + {% block dojo_css %} - + {% endblock %} - - + + + + - + + + {% block pre_wrapper %} {% endblock pre_wrapper %} -
- {% block navigation %} - -
- + - - + - + + {% block extra_javascript %} diff --git a/dojo/templates/defectDojo-engagement-survey/add_choices.html b/dojo/templates/defectDojo-engagement-survey/add_choices.html index aa6fc1b2062..db3dae1c3e8 100644 --- a/dojo/templates/defectDojo-engagement-survey/add_choices.html +++ b/dojo/templates/defectDojo-engagement-survey/add_choices.html @@ -1,9 +1,10 @@ {% extends "base.html" %} +{% load static %} {% block content %} {{ block.super }}

Add Choice

-
{% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
@@ -14,5 +15,5 @@

Add Choice

{% endblock %} {% block postscript %} {{ block.super }} - + {% endblock %} \ No newline at end of file diff --git a/dojo/templates/defectDojo-engagement-survey/add_engagement.html b/dojo/templates/defectDojo-engagement-survey/add_engagement.html index c78ed59c066..6b7b15cc09a 100644 --- a/dojo/templates/defectDojo-engagement-survey/add_engagement.html +++ b/dojo/templates/defectDojo-engagement-survey/add_engagement.html @@ -2,7 +2,7 @@ {% block content %} {{ block.super }}

Link Questionnaire to New Engagement

- + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/templates/defectDojo-engagement-survey/add_survey.html b/dojo/templates/defectDojo-engagement-survey/add_survey.html index eb21802cf84..7ae24d72d8d 100644 --- a/dojo/templates/defectDojo-engagement-survey/add_survey.html +++ b/dojo/templates/defectDojo-engagement-survey/add_survey.html @@ -7,7 +7,7 @@

Add Questionnaire to {{engagement}}

Add Unlinked Questionnaire

{% endif %} {% if surveys %} - {% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/templates/defectDojo-engagement-survey/assign_survey.html b/dojo/templates/defectDojo-engagement-survey/assign_survey.html index ef43e25bdd1..7843f972667 100644 --- a/dojo/templates/defectDojo-engagement-survey/assign_survey.html +++ b/dojo/templates/defectDojo-engagement-survey/assign_survey.html @@ -2,7 +2,7 @@ {% block content %} {{ block.super }}

Assign User to Questionnaire {{ survey }}

- {% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/templates/defectDojo-engagement-survey/create_questionnaire.html b/dojo/templates/defectDojo-engagement-survey/create_questionnaire.html index 864f7fa255d..6772cc244de 100644 --- a/dojo/templates/defectDojo-engagement-survey/create_questionnaire.html +++ b/dojo/templates/defectDojo-engagement-survey/create_questionnaire.html @@ -3,7 +3,7 @@ {% block content %} {{ block.super }}

Create New Questionnaire

- {% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/templates/defectDojo-engagement-survey/create_related_question.html b/dojo/templates/defectDojo-engagement-survey/create_related_question.html index 28037fc6f5d..54c290e9dc5 100644 --- a/dojo/templates/defectDojo-engagement-survey/create_related_question.html +++ b/dojo/templates/defectDojo-engagement-survey/create_related_question.html @@ -1,47 +1,12 @@ {% extends "base.html" %} - - - - - - - - - - - DefectDojo - {{ name }} - - - - - - - - - - - - - - - - - - - - - - +{% load static %} {% block content %} {{ block.super }}
- + {{ name }} {% csrf_token %} {% include "dojo/form_fields.html" with form=form %} @@ -66,14 +31,14 @@ -{% endblock postscript %} \ No newline at end of file +{% endblock postscript %} diff --git a/dojo/templates/defectDojo-engagement-survey/delete_questionnaire.html b/dojo/templates/defectDojo-engagement-survey/delete_questionnaire.html index 2722990b468..7f790a4a8f6 100644 --- a/dojo/templates/defectDojo-engagement-survey/delete_questionnaire.html +++ b/dojo/templates/defectDojo-engagement-survey/delete_questionnaire.html @@ -23,7 +23,7 @@

Danger Zone

{% else %}

No relationships found.

{% endif %} - + {% csrf_token %} {{ form }} diff --git a/dojo/templates/defectDojo-engagement-survey/edit_question.html b/dojo/templates/defectDojo-engagement-survey/edit_question.html index 798a1e208a1..ae41a2206cd 100644 --- a/dojo/templates/defectDojo-engagement-survey/edit_question.html +++ b/dojo/templates/defectDojo-engagement-survey/edit_question.html @@ -1,13 +1,10 @@ {% extends "base.html" %} -{% block add_css %} - {{ block.super }} - -{% endblock %} +{% load static %} {% block content %} {{ block.super }}

Edit Question: {{ question.name }}

- {% csrf_token %} + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
@@ -26,5 +23,5 @@

Edit Question: {{ question.name }}

}); }); - + {% endblock %} \ No newline at end of file diff --git a/dojo/templates/defectDojo-engagement-survey/edit_survey_questions.html b/dojo/templates/defectDojo-engagement-survey/edit_survey_questions.html index 9bf921b56ec..b4a04beeaf5 100644 --- a/dojo/templates/defectDojo-engagement-survey/edit_survey_questions.html +++ b/dojo/templates/defectDojo-engagement-survey/edit_survey_questions.html @@ -3,7 +3,7 @@ {{ block.super }}

Edit Questionnaire Questions ({{ survey.name }})

- {% csrf_token %} + {% csrf_token %}
{% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/templates/defectDojo-engagement-survey/existing_engagement.html b/dojo/templates/defectDojo-engagement-survey/existing_engagement.html index 7226397d6f3..2577bfaf2c9 100644 --- a/dojo/templates/defectDojo-engagement-survey/existing_engagement.html +++ b/dojo/templates/defectDojo-engagement-survey/existing_engagement.html @@ -3,7 +3,7 @@ {{ block.super }}

Link Questionnaire to Existing Engagement


- + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
diff --git a/dojo/templates/defectDojo-engagement-survey/list_surveys.html b/dojo/templates/defectDojo-engagement-survey/list_surveys.html index 7dd2ea10068..c335684d353 100644 --- a/dojo/templates/defectDojo-engagement-survey/list_surveys.html +++ b/dojo/templates/defectDojo-engagement-survey/list_surveys.html @@ -71,9 +71,8 @@ {{ survey.expiration }}
- {% if "dojo.delete_engagement_survey"|has_configuration_permission:request %} @@ -127,44 +126,36 @@
-