trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,2837 @@
|
|
|
1
|
+
"""DRF permission classes and ProjectScopedViewSet mixin for RBAC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from django.db.models import QuerySet
|
|
11
|
+
from rest_framework import viewsets
|
|
12
|
+
from rest_framework.authentication import BaseAuthentication
|
|
13
|
+
from rest_framework.exceptions import PermissionDenied
|
|
14
|
+
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
|
15
|
+
from rest_framework.request import Request
|
|
16
|
+
from rest_framework.throttling import BaseThrottle
|
|
17
|
+
from rest_framework.views import APIView
|
|
18
|
+
|
|
19
|
+
from trueppm_api.apps.access.models import ProjectMembership, Role
|
|
20
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_project_id_from_obj(obj: Any) -> Any | None:
|
|
30
|
+
"""Extract the project PK from a model instance.
|
|
31
|
+
|
|
32
|
+
Supports direct Project instances as well as any model with a project_id
|
|
33
|
+
or project attribute (Task, Dependency, etc.).
|
|
34
|
+
|
|
35
|
+
Uses isinstance to identify Project to avoid false-positives from future
|
|
36
|
+
models that happen to have a 'memberships' attribute (M3 fix).
|
|
37
|
+
"""
|
|
38
|
+
# Import here to avoid a module-level circular import (access → projects).
|
|
39
|
+
from trueppm_api.apps.projects.models import Project
|
|
40
|
+
|
|
41
|
+
if isinstance(obj, Project):
|
|
42
|
+
return obj.pk
|
|
43
|
+
if hasattr(obj, "project_id"):
|
|
44
|
+
return obj.project_id
|
|
45
|
+
if hasattr(obj, "project"):
|
|
46
|
+
return obj.project_id
|
|
47
|
+
# NOTE (#3414): a `task_id -> task.project_id` hop was added here and then removed,
|
|
48
|
+
# because it was dead on arrival. Every task-scoped model that reaches this helper —
|
|
49
|
+
# TaskResource, AcceptanceCriterion, TaskRecurrenceRule, SprintTaskOutcome,
|
|
50
|
+
# CrossProjectSlipConflict — already exposes a `project_id` **property** that walks
|
|
51
|
+
# the relation itself, so the branch above returns first and the hop never ran.
|
|
52
|
+
# The only models it did reach (RiskTask, PokerSession, TimeEntry, ActiveTimer,
|
|
53
|
+
# TaskActivityEvent, …) are served by views that never route them through an
|
|
54
|
+
# object-level project check, and resolving them would have flipped eleven
|
|
55
|
+
# fail-closed classes from "deny" to "role-based grant" on those rows — a widening,
|
|
56
|
+
# in the middle of a tightening. If a task-scoped model ever does need this, give it
|
|
57
|
+
# a `project_id` property like its siblings rather than widening the resolver.
|
|
58
|
+
#
|
|
59
|
+
# Dependency — look through predecessor__project_id
|
|
60
|
+
if hasattr(obj, "predecessor_id"):
|
|
61
|
+
predecessor = getattr(obj, "predecessor", None)
|
|
62
|
+
if predecessor is not None:
|
|
63
|
+
return predecessor.project_id
|
|
64
|
+
return None
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: Attribute names the per-request RBAC role caches live under. Read (never written)
|
|
69
|
+
#: by the OTel request-span annotator, which sources ``trueppm.user.role`` from them
|
|
70
|
+
#: so it costs no extra query — see ``observability/otel/request_attributes.py``.
|
|
71
|
+
RBAC_ROLE_CACHE_ATTR = "_rbac_role_cache"
|
|
72
|
+
PROGRAM_RBAC_ROLE_CACHE_ATTR = "_program_rbac_role_cache"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cache_host(request: Request) -> Any:
|
|
76
|
+
"""Return the object a per-request RBAC cache should be stored on.
|
|
77
|
+
|
|
78
|
+
The underlying Django ``HttpRequest`` rather than the DRF ``Request`` wrapper.
|
|
79
|
+
Lifetime and per-request identity are identical either way — DRF builds exactly
|
|
80
|
+
one wrapper per ``HttpRequest`` — but only the Django request survives into
|
|
81
|
+
``process_response``, which is where OTel's Django ``response_hook`` runs and
|
|
82
|
+
where ``trueppm.user.role`` is read from (#2880). Reads through the DRF wrapper
|
|
83
|
+
keep working: DRF's ``Request.__getattr__`` proxies any unknown attribute to
|
|
84
|
+
``self._request``.
|
|
85
|
+
"""
|
|
86
|
+
return getattr(request, "_request", request)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _membership_role(request: Request, project_id: Any) -> int | None:
|
|
90
|
+
"""Return the requesting user's role ordinal for a project, or None if absent.
|
|
91
|
+
|
|
92
|
+
Results are cached on the request object to prevent N+1 queries on list
|
|
93
|
+
endpoints where has_object_permission is called once per row (L1 fix).
|
|
94
|
+
The cache is keyed by str(project_id) and lives only for the request lifetime.
|
|
95
|
+
|
|
96
|
+
Only active (non-soft-deleted) memberships are considered (M1 fix).
|
|
97
|
+
"""
|
|
98
|
+
if not request.user or not request.user.is_authenticated:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# Per-request cache, lazily initialised on the underlying Django request.
|
|
102
|
+
host = _cache_host(request)
|
|
103
|
+
cache: dict[str, int | None] | None = getattr(host, RBAC_ROLE_CACHE_ATTR, None)
|
|
104
|
+
if cache is None:
|
|
105
|
+
cache = {}
|
|
106
|
+
setattr(host, RBAC_ROLE_CACHE_ATTR, cache)
|
|
107
|
+
|
|
108
|
+
cache_key = str(project_id)
|
|
109
|
+
if cache_key in cache:
|
|
110
|
+
return cache[cache_key]
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
membership = ProjectMembership.objects.get(
|
|
114
|
+
project_id=project_id,
|
|
115
|
+
user=request.user,
|
|
116
|
+
is_deleted=False, # M1: exclude soft-deleted memberships
|
|
117
|
+
)
|
|
118
|
+
role: int | None = membership.role
|
|
119
|
+
except ProjectMembership.DoesNotExist:
|
|
120
|
+
role = None
|
|
121
|
+
|
|
122
|
+
cache[cache_key] = role
|
|
123
|
+
return role
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_product_owner(request: Request, project_id: Any) -> bool:
|
|
127
|
+
"""Request-cached ``is_product_owner`` facet check (ADR-0078).
|
|
128
|
+
|
|
129
|
+
``can_user_edit_task`` is evaluated once per task row on list endpoints, and a
|
|
130
|
+
Product Owner grooming a large EPIC/STORY backlog is exactly the persona who
|
|
131
|
+
loads the biggest such list. The underlying ``has_team_facet`` lookup is a DB
|
|
132
|
+
query, so without this cache the PO path would be N queries for N rows. The
|
|
133
|
+
facet is constant per (user, project) for the request, so memoize it on the
|
|
134
|
+
request object the same way ``_membership_role`` caches the role.
|
|
135
|
+
"""
|
|
136
|
+
cache: dict[str, bool] | None = getattr(request, "_rbac_po_facet_cache", None)
|
|
137
|
+
if cache is None:
|
|
138
|
+
cache = {}
|
|
139
|
+
request._rbac_po_facet_cache = cache # type: ignore[attr-defined]
|
|
140
|
+
|
|
141
|
+
cache_key = str(project_id)
|
|
142
|
+
if cache_key in cache:
|
|
143
|
+
return cache[cache_key]
|
|
144
|
+
|
|
145
|
+
from trueppm_api.apps.teams.services import has_team_facet
|
|
146
|
+
|
|
147
|
+
result = bool(has_team_facet(request.user, project_id, "is_product_owner"))
|
|
148
|
+
cache[cache_key] = result
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def can_user_edit_task(request: Request, task: Any, *, method: str = "PATCH") -> bool:
|
|
153
|
+
"""Authoritative "may this user write this task" predicate (ADR-0133).
|
|
154
|
+
|
|
155
|
+
This is the single source of truth for task-edit permission. It backs BOTH
|
|
156
|
+
enforcement (``IsProjectMemberWriteOrOwn.has_object_permission``) and the
|
|
157
|
+
declarative ``TaskSerializer.can_edit`` / ``can_delete`` fields, so the
|
|
158
|
+
contract the client gates off can never drift from the contract the server
|
|
159
|
+
enforces. There is one rule; it is called twice.
|
|
160
|
+
|
|
161
|
+
``method`` is the would-be write verb: ``"DELETE"`` excludes the Product
|
|
162
|
+
Owner facet branch (a PO may groom — edit — EPIC/STORY items, but removing
|
|
163
|
+
another member's story stays an Admin/assignee act), so ``can_edit`` and
|
|
164
|
+
``can_delete`` legitimately differ for a PO.
|
|
165
|
+
|
|
166
|
+
Fails closed: any unresolved context (no auth, no membership) yields
|
|
167
|
+
``False`` — never an exception, never an over-permissive ``True``.
|
|
168
|
+
"""
|
|
169
|
+
if not (request.user and request.user.is_authenticated):
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
project_id = getattr(task, "project_id", None)
|
|
173
|
+
if project_id is None:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
role = _membership_role(request, project_id)
|
|
177
|
+
if role is None:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# Project Manager (3) and Project Admin (4): full write on any task.
|
|
181
|
+
if role >= Role.ADMIN:
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
# Product Owner facet (ADR-0078 / #1095): edits EPIC/STORY work items below
|
|
185
|
+
# Admin and regardless of assignment — but never DELETE (see docstring). The
|
|
186
|
+
# facet lookup is request-cached so a PO grooming a large backlog stays O(1).
|
|
187
|
+
if method != "DELETE":
|
|
188
|
+
from trueppm_api.apps.projects.models import TaskType
|
|
189
|
+
|
|
190
|
+
if getattr(task, "type", None) in (
|
|
191
|
+
TaskType.EPIC,
|
|
192
|
+
TaskType.STORY,
|
|
193
|
+
) and _is_product_owner(request, project_id):
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
# Resource Manager (2): cannot edit task content (only resource assignment).
|
|
197
|
+
if role == Role.SCHEDULER:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
# Team Member (1): may only edit their own assigned tasks.
|
|
201
|
+
if role == Role.MEMBER:
|
|
202
|
+
assignee_id = getattr(task, "assignee_id", None)
|
|
203
|
+
return assignee_id is not None and assignee_id == request.user.pk
|
|
204
|
+
|
|
205
|
+
# Viewer (0): no writes.
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def can_user_author_plan(request: Request, project: Any) -> bool:
|
|
210
|
+
"""Authoritative "may this user author a plan" predicate (ADR-0773 §2).
|
|
211
|
+
|
|
212
|
+
Backs BOTH enforcement (:class:`IsProjectPlanAuthor` on the task-authoring
|
|
213
|
+
endpoints) and the declarative ``ProjectSerializer.can_author`` field, so the
|
|
214
|
+
Designer's Read/Author toggle reads the exact rule the server enforces — the
|
|
215
|
+
ADR-0133 "one rule, called twice" pattern.
|
|
216
|
+
|
|
217
|
+
The rule is ``role >= Role.MEMBER`` **minus the resource-management band**::
|
|
218
|
+
|
|
219
|
+
role >= Role.MEMBER and not (Role.SCHEDULER <= role < Role.ADMIN)
|
|
220
|
+
|
|
221
|
+
A plain ``role >= Role.MEMBER`` is wrong here, and the reason is a live defect
|
|
222
|
+
rather than a hypothetical: ``Role.SCHEDULER`` (200) is ordinally *above* Member
|
|
223
|
+
but :func:`can_user_edit_task` refuses it task content outright, while
|
|
224
|
+
:class:`IsProjectMemberWrite` admits it. So a Scheduler can create a task and
|
|
225
|
+
then cannot edit or delete the task they just created. Survivable in a modal —
|
|
226
|
+
one task, one 403 — but in a keyboard-fast row grid it is a trap: the rows
|
|
227
|
+
commit and every subsequent keystroke 403s. Author mode is therefore an explicit
|
|
228
|
+
deny for the whole band, not an emergent property of a ``>=`` comparison.
|
|
229
|
+
|
|
230
|
+
The band-range form (``Role.SCHEDULER <= role < Role.ADMIN``) rather than
|
|
231
|
+
``role == Role.SCHEDULER`` is required by ADR-0072's band contract: an
|
|
232
|
+
Enterprise custom role registered in the 201-299 resource-management band
|
|
233
|
+
inherits the same exclusion, instead of silently gaining authoring rights the
|
|
234
|
+
OSS tier it sits beside does not have.
|
|
235
|
+
|
|
236
|
+
This is a *project*-level capability — "may this user enter Author mode at all".
|
|
237
|
+
It does not decide which rows they may touch; that stays with
|
|
238
|
+
:func:`can_user_edit_task`, applied per row (ADR-0773 §3).
|
|
239
|
+
|
|
240
|
+
Fails closed: unresolved auth, membership, or project yields ``False``.
|
|
241
|
+
"""
|
|
242
|
+
if project is None:
|
|
243
|
+
return False
|
|
244
|
+
project_id = getattr(project, "pk", None)
|
|
245
|
+
if project_id is None:
|
|
246
|
+
return False
|
|
247
|
+
return _can_author_plan_for_project_id(request, project_id)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def role_can_author_plan(role: int | None) -> bool:
|
|
251
|
+
"""The plan-authoring rule, as a pure function of the role ordinal (ADR-0773 §2).
|
|
252
|
+
|
|
253
|
+
The single implementation. :func:`can_user_author_plan` and
|
|
254
|
+
:class:`IsProjectPlanAuthor` resolve a role and call this; the serializer's
|
|
255
|
+
``can_author`` field calls it against the viewset's ``_my_role`` annotation so a
|
|
256
|
+
project list does not pay a membership query per row. Mirrors the existing
|
|
257
|
+
:func:`can_manage_backlog` ordinal-predicate shape.
|
|
258
|
+
"""
|
|
259
|
+
if role is None:
|
|
260
|
+
return False
|
|
261
|
+
if Role.SCHEDULER <= role < Role.ADMIN:
|
|
262
|
+
return False
|
|
263
|
+
return role >= Role.MEMBER
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _can_author_plan_for_project_id(request: Request, project_id: Any) -> bool:
|
|
267
|
+
"""Resolve the caller's role on ``project_id`` and apply the authoring rule.
|
|
268
|
+
|
|
269
|
+
Split out from :func:`can_user_author_plan` only so the permission class can
|
|
270
|
+
reach it from a URL kwarg without loading a ``Project`` row it does not
|
|
271
|
+
otherwise need.
|
|
272
|
+
"""
|
|
273
|
+
if not (request.user and request.user.is_authenticated):
|
|
274
|
+
return False
|
|
275
|
+
if project_id is None:
|
|
276
|
+
return False
|
|
277
|
+
return role_can_author_plan(_membership_role(request, str(project_id)))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def role_can_undo_batch_operation(role: int | None) -> bool:
|
|
281
|
+
"""May this role reverse a recorded batch write? (ADR-0810, ADR-0773's undo matrix)
|
|
282
|
+
|
|
283
|
+
Admin+, which is a **higher floor than the writes it reverses**: paste-many and
|
|
284
|
+
the classification cascade are both ``IsProjectPlanAuthor`` (Member+ minus the
|
|
285
|
+
resource-management band), so a Member can author a batch they may not undo.
|
|
286
|
+
**Whether that asymmetry is right is OPEN — see #3355.** Do not read the floor
|
|
287
|
+
here as settled, and in particular do not justify it with the sentence this
|
|
288
|
+
docstring used to carry ("undoing removes work other collaborators may already be
|
|
289
|
+
building on top of"). All three batch undos partition their snapshot on
|
|
290
|
+
``server_version`` via ``task_batch_services._partition_touched`` and write back
|
|
291
|
+
only the untouched rows, reporting the rest as ``kept`` — so a row another
|
|
292
|
+
collaborator has changed is left alone by construction and that hazard is
|
|
293
|
+
unreachable. ADR-0880 §4 reached the same conclusion for structural undo (which
|
|
294
|
+
*refuses* rather than skipping) and implements actor-or-Admin on it; ADR-0810
|
|
295
|
+
says only "mirroring template-apply", and template-apply is symmetric
|
|
296
|
+
(apply Admin+ *and* undo Admin+), so the asymmetry here came from copying half
|
|
297
|
+
of a symmetric rule onto writes whose apply floor is Member+.
|
|
298
|
+
|
|
299
|
+
What is settled, and is why this is a *shared* predicate rather than an inline
|
|
300
|
+
comparison at each site: the apply endpoint has to tell the client which of the
|
|
301
|
+
two floors the caller cleared, and a client that re-derives the rule drifts from
|
|
302
|
+
it (#3304).
|
|
303
|
+
|
|
304
|
+
A threshold, not a band exclusion, so the ADR-0072 band contract applies: an
|
|
305
|
+
Enterprise custom role registered in the 301-399 project-lead band inherits it.
|
|
306
|
+
Contrast :func:`role_can_author_plan`, whose rule genuinely is a band exclusion —
|
|
307
|
+
which is why *that* one could never be a client-side ``>=`` and this one could.
|
|
308
|
+
|
|
309
|
+
Fails closed: ``None`` (unresolved auth, or no membership) is ``False``.
|
|
310
|
+
"""
|
|
311
|
+
return role is not None and role >= Role.ADMIN
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def can_user_undo_batch_operation(request: Request, project_id: Any) -> bool:
|
|
315
|
+
"""Resolve the caller's role on ``project_id`` and apply the undo rule.
|
|
316
|
+
|
|
317
|
+
Backs the declarative ``can_undo`` field on the classification cascade's own 200
|
|
318
|
+
response, and — since #3357 — ``ProjectSerializer.can_undo_batch_operations``,
|
|
319
|
+
which answers the same question *before* the act so a surface can disclose the
|
|
320
|
+
asymmetry rather than only withhold the Undo afterwards. Enforcement calls
|
|
321
|
+
:func:`role_can_undo_batch_operation` directly (from
|
|
322
|
+
``batch_operation_views._require_admin``, which already has the role in hand), so
|
|
323
|
+
all of them share the *predicate* rather than this wrapper — the ADR-0133 "one
|
|
324
|
+
rule, called twice" pattern that ``can_author`` follows. Change the predicate, not
|
|
325
|
+
any caller.
|
|
326
|
+
|
|
327
|
+
Two honest limits on that guarantee, so nobody reads it as stronger than it is:
|
|
328
|
+
|
|
329
|
+
- **The field is a snapshot, enforcement is live.** ``TaskClassificationView``
|
|
330
|
+
carries ``IdempotencyMixin``, which stores the rendered body and replays it on
|
|
331
|
+
a repeated ``Idempotency-Key`` without re-running the view; the request hash
|
|
332
|
+
covers method, path and body, not the caller's role. So a caller demoted after
|
|
333
|
+
a cascade can be replayed a stale ``can_undo: true`` inside the retention
|
|
334
|
+
window. Harmless — the undo endpoint re-derives the role and still refuses —
|
|
335
|
+
but the client's affordance is advisory, never the gate.
|
|
336
|
+
- **One sibling still disagrees about the floor, deliberately.**
|
|
337
|
+
``csvimport.views._require_project_admin`` and ``template_views``'s ``undo``
|
|
338
|
+
action inlined this same comparison until #3353; both now call
|
|
339
|
+
:func:`role_can_undo_batch_operation`, so every batch-undo enforcement site is a
|
|
340
|
+
caller rather than a copy. ``structural_operation_services`` remains the
|
|
341
|
+
exception and implements *actor-or-Admin* instead, on the argument that the undo
|
|
342
|
+
skips rows touched since. Whether the batch-undo floor should follow it is a real
|
|
343
|
+
open question, not an oversight here; see #3355.
|
|
344
|
+
"""
|
|
345
|
+
if not (request.user and request.user.is_authenticated):
|
|
346
|
+
return False
|
|
347
|
+
if project_id is None:
|
|
348
|
+
return False
|
|
349
|
+
return role_can_undo_batch_operation(_membership_role(request, str(project_id)))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def can_user_write_estimates(request: Request, project: Any) -> bool:
|
|
353
|
+
"""Authoritative "may this user write three-point estimates" predicate (ADR-0743).
|
|
354
|
+
|
|
355
|
+
Backs BOTH enforcement (``TaskSerializer._validate_estimate_write_permitted``)
|
|
356
|
+
and the declarative ``TaskSerializer.can_edit_estimates`` field, so the contract
|
|
357
|
+
the client gates off cannot drift from the one the server enforces — the
|
|
358
|
+
ADR-0133 "one rule, called twice" pattern.
|
|
359
|
+
|
|
360
|
+
Only ``EstimationMode.PM_ONLY`` restricts *who* may write. It requires
|
|
361
|
+
``Role.ADMIN`` (Project Manager) or above:
|
|
362
|
+
|
|
363
|
+
- ``Role.SCHEDULER`` is deliberately **not** admitted. It is labelled "Resource
|
|
364
|
+
Manager" and :func:`can_user_edit_task` already refuses it task content
|
|
365
|
+
outright, so admitting it would be unreachable. The ``PM_ONLY`` docstring
|
|
366
|
+
formerly said "Scheduler-role", which is what made the wrong threshold look
|
|
367
|
+
plausible (#2596).
|
|
368
|
+
- The Product Owner facet (ADR-0078) is **not** admitted either. A PO below
|
|
369
|
+
Admin may groom EPIC/STORY items, but writing a PERT duration is a scheduling
|
|
370
|
+
act, not grooming, and ``PM_ONLY`` exists to reserve estimates to the PM.
|
|
371
|
+
- ``>=`` (not ``==``) is required by ADR-0072's band contract: Enterprise custom
|
|
372
|
+
roles registered at 301-399 are meant to inherit the Admin band's
|
|
373
|
+
capabilities, exactly as :func:`can_user_edit_task` already grants them full
|
|
374
|
+
task write.
|
|
375
|
+
|
|
376
|
+
``OPEN`` and ``SUGGEST_APPROVE`` place no role restriction here — under
|
|
377
|
+
``SUGGEST_APPROVE`` a Contributor write is *permitted* and lands ``pending``,
|
|
378
|
+
withheld from Monte Carlo until approved (``scheduling/services.py``).
|
|
379
|
+
|
|
380
|
+
Fails closed: unresolved auth, membership, or project yields ``False``.
|
|
381
|
+
"""
|
|
382
|
+
if not (request.user and request.user.is_authenticated):
|
|
383
|
+
return False
|
|
384
|
+
if project is None:
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
from trueppm_api.apps.projects.models import EstimationMode
|
|
388
|
+
|
|
389
|
+
if getattr(project, "estimation_mode", None) != EstimationMode.PM_ONLY:
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
role = _membership_role(request, str(project.pk))
|
|
393
|
+
return role is not None and role >= Role.ADMIN
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def can_user_log_time(request: Request, task: Any) -> bool:
|
|
397
|
+
"""Authoritative "may this user log time against this task" predicate (ADR-0185 §3).
|
|
398
|
+
|
|
399
|
+
The single source of truth for time-log permission — it backs BOTH the
|
|
400
|
+
``CanLogTime`` permission class (enforcement) and ``TaskSerializer.can_log_time``
|
|
401
|
+
(declaration), so the client's gate can never drift from the server's rule.
|
|
402
|
+
|
|
403
|
+
Deliberately diverges from ``can_user_edit_task``: logging time records *where my
|
|
404
|
+
hours went*, so a Team Member may log against **any** task on a project they belong
|
|
405
|
+
to (a meeting, a colleague's task they helped on) — not only their own assigned
|
|
406
|
+
tasks. The entry it gates is owned by the logger (``user`` is server-set to
|
|
407
|
+
``request.user``), so this is IDOR-safe by construction.
|
|
408
|
+
|
|
409
|
+
Rule: ``role >= Role.MEMBER`` on ``task.project``. Viewer (0) is denied; Member (1),
|
|
410
|
+
Scheduler (2), Admin (3), Owner (4) — and Enterprise custom roles ≥ 100 by the
|
|
411
|
+
band-threshold contract — may log. Fails closed: no auth / no membership / no
|
|
412
|
+
resolvable project yields ``False`` (never an exception, never over-permissive).
|
|
413
|
+
"""
|
|
414
|
+
if not (request.user and request.user.is_authenticated):
|
|
415
|
+
return False
|
|
416
|
+
project_id = getattr(task, "project_id", None)
|
|
417
|
+
if project_id is None:
|
|
418
|
+
return False
|
|
419
|
+
role = _membership_role(request, project_id)
|
|
420
|
+
return role is not None and role >= Role.MEMBER
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
# Permission classes
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _mark_policy_refusal(request: Request, constraint: str) -> None:
|
|
429
|
+
"""Record why an MCP guard is about to deny, for the response body (#2689).
|
|
430
|
+
|
|
431
|
+
The guards keep returning ``False`` rather than raising: they are fail-closed
|
|
432
|
+
by construction and that is not worth trading for a richer body. Marking is
|
|
433
|
+
advisory — an unmarked denial still denies, just with DRF's generic detail,
|
|
434
|
+
which is the pre-existing behavior. The envelope is attached centrally in
|
|
435
|
+
:mod:`trueppm_api.core.exception_handlers`.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
from trueppm_api.apps.agents.models import AgentActionRefusalReason
|
|
439
|
+
from trueppm_api.apps.agents.refusal import mark_refusal
|
|
440
|
+
|
|
441
|
+
mark_refusal(request, AgentActionRefusalReason.POLICY, constraint)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _mark_identity_refusal(request: Request, constraint: str) -> None:
|
|
445
|
+
"""As :func:`_mark_policy_refusal`, for a refusal about the credential itself."""
|
|
446
|
+
|
|
447
|
+
from trueppm_api.apps.agents.models import AgentActionRefusalReason
|
|
448
|
+
from trueppm_api.apps.agents.refusal import mark_refusal
|
|
449
|
+
|
|
450
|
+
mark_refusal(request, AgentActionRefusalReason.IDENTITY, constraint)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _project_exists(project_pk: Any) -> bool:
|
|
454
|
+
"""Does this project id name a real, live project? (#2745)
|
|
455
|
+
|
|
456
|
+
Used to keep an unknown id a **404** rather than a 403. `_membership_role`
|
|
457
|
+
returns None for "no membership" and for "no such project" alike, so a
|
|
458
|
+
permission class enforcing on a resolved kwarg would answer 403 to both — and
|
|
459
|
+
22 endpoints publish a 404 for an unknown project in `docs/api/openapi.json`.
|
|
460
|
+
Turning those into 403s would be an API contract change, made as a side effect
|
|
461
|
+
of an internal permission fix rather than as a decision.
|
|
462
|
+
|
|
463
|
+
So when the project does not exist the permission layer stands down and lets
|
|
464
|
+
the view's own `get_object_or_404` answer. Nothing is exposed by that: there is
|
|
465
|
+
no object to expose, and the view still runs its own object check for ids that
|
|
466
|
+
DO resolve.
|
|
467
|
+
|
|
468
|
+
Note what this deliberately does NOT change: an existing project the caller
|
|
469
|
+
cannot see still answers 403 while an unknown id answers 404, so the pair
|
|
470
|
+
remains distinguishable to a prober. That is the behavior on `main` today, it
|
|
471
|
+
is unchanged here, and making it uniform is a separate decision — the two
|
|
472
|
+
conventions already coexist in this codebase (`ProjectViewSet` hides existence
|
|
473
|
+
behind a queryset filter and 404s instead). Widening this fix into that one
|
|
474
|
+
would bury an API-visible security decision inside a resolver bugfix.
|
|
475
|
+
|
|
476
|
+
Only reached on the deny path — a caller with a membership row never gets here —
|
|
477
|
+
so the happy path costs no extra query.
|
|
478
|
+
"""
|
|
479
|
+
from trueppm_api.apps.projects.models import Project
|
|
480
|
+
|
|
481
|
+
return Project.objects.filter(pk=project_pk, is_deleted=False).exists()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
#: Default URL kwarg naming the project. A view whose route spells it differently
|
|
485
|
+
#: overrides this with :attr:`project_url_kwarg` — see ``_project_pk_from_view``.
|
|
486
|
+
DEFAULT_PROJECT_URL_KWARG = "project_pk"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class ScopeResolution(StrEnum):
|
|
490
|
+
"""Why a scope lookup did or did not produce an id (#3767).
|
|
491
|
+
|
|
492
|
+
A bare ``None`` from the resolvers below used to encode three different
|
|
493
|
+
situations, and every caller had to state one of them as fact — which is
|
|
494
|
+
precisely how fourteen permission classes ended up answering ``True`` to all
|
|
495
|
+
three. Widening the discriminator is what lets the ABSENT case fail closed
|
|
496
|
+
while the UNKNOWN_ID case keeps standing down.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
#: A project/program id was found. Role resolution proceeds normally.
|
|
500
|
+
RESOLVED = "resolved"
|
|
501
|
+
#: The route names no project/program for this view — a genuinely top-level
|
|
502
|
+
#: route, or one whose scope arrives in the request body. This is the case
|
|
503
|
+
#: that used to fail open and now does not (see :func:`_unresolved_scope_allows`).
|
|
504
|
+
ABSENT = "absent"
|
|
505
|
+
#: The view DID name a kwarg and it DID carry a value, but no live project has
|
|
506
|
+
#: that id. Deliberately still permissive: the view's own ``get_object_or_404``
|
|
507
|
+
#: answers 404, and turning that into a 403 would rebuild the membership-scoped
|
|
508
|
+
#: existence oracle #3129 closed. See ``_project_exists``.
|
|
509
|
+
UNKNOWN_ID = "unknown_id"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
#: Attribute a view sets to declare that it resolves project/program scope in its
|
|
513
|
+
#: own body, and is therefore allowed to reach that body on an unsafe method that
|
|
514
|
+
#: the permission layer cannot scope (#3767). Either a string (the whole view) or a
|
|
515
|
+
#: ``{action_or_method: reason}`` mapping when only some of a viewset's actions need
|
|
516
|
+
#: it — ``TaskViewSet.create`` does, ``TaskViewSet.partial_update`` does not.
|
|
517
|
+
#:
|
|
518
|
+
#: It lives ON THE VIEW, not in a list, for the same reason ``archived_write_exempt``
|
|
519
|
+
#: does: the reason travels with the code it excuses. The set of views carrying one is
|
|
520
|
+
#: pinned by name in ``tests/apps/access/test_route_table_invariants.py`` so adding one
|
|
521
|
+
#: is a reviewable diff rather than one more quiet attribute.
|
|
522
|
+
SCOPE_IN_BODY_ATTR = "resolves_scope_in_body"
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
_ViewTarget = TypeVar("_ViewTarget")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def declare_scope_in_body(reason: str) -> Callable[[_ViewTarget], _ViewTarget]:
|
|
529
|
+
"""Set :data:`SCOPE_IN_BODY_ATTR` on a view class or an ``@api_view`` function.
|
|
530
|
+
|
|
531
|
+
Classes can simply assign the attribute. ``@api_view`` functions cannot: the
|
|
532
|
+
decorator hands back ``WrappedAPIView.as_view()``, a plain function, and the
|
|
533
|
+
generated class is only reachable through its ``cls`` attribute. Applied ABOVE
|
|
534
|
+
``@api_view`` so it sees that function::
|
|
535
|
+
|
|
536
|
+
@declare_scope_in_body("... why ...")
|
|
537
|
+
@api_view(["POST"])
|
|
538
|
+
@permission_classes([IsProjectScheduler])
|
|
539
|
+
def trigger_schedule(request, pk): ...
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
def decorate(view: Any) -> Any:
|
|
543
|
+
setattr(getattr(view, "cls", view), SCOPE_IN_BODY_ATTR, reason)
|
|
544
|
+
return view
|
|
545
|
+
|
|
546
|
+
return decorate
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _scope_in_body_reason(view: APIView) -> str | None:
|
|
550
|
+
"""The stated reason this (view, action) resolves scope in its own body, or None.
|
|
551
|
+
|
|
552
|
+
Only a real, non-empty string counts, in both the flat and the mapping form. A
|
|
553
|
+
``MagicMock`` view synthesizes a truthy non-string for any attribute, and a view
|
|
554
|
+
that set the attribute to a bare ``True`` would be claiming an exemption nobody
|
|
555
|
+
wrote a sentence for — both must read as "no declaration" and therefore deny,
|
|
556
|
+
the same fail-closed reading ``_project_pk_from_view`` already applies to a
|
|
557
|
+
non-string ``project_url_kwarg``.
|
|
558
|
+
"""
|
|
559
|
+
declared = getattr(view, SCOPE_IN_BODY_ATTR, None)
|
|
560
|
+
if isinstance(declared, str):
|
|
561
|
+
return declared.strip() or None
|
|
562
|
+
if isinstance(declared, dict):
|
|
563
|
+
# Viewsets key by action; APIViews have no action, so key by lowercased method.
|
|
564
|
+
key = getattr(view, "action", None)
|
|
565
|
+
if not isinstance(key, str):
|
|
566
|
+
key = (getattr(getattr(view, "request", None), "method", "") or "").lower()
|
|
567
|
+
value = declared.get(key)
|
|
568
|
+
if isinstance(value, str):
|
|
569
|
+
return value.strip() or None
|
|
570
|
+
return None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _object_permission_will_run(view: APIView) -> bool:
|
|
574
|
+
"""Whether DRF will reach ``has_object_permission`` for this request.
|
|
575
|
+
|
|
576
|
+
True for a ViewSet detail route: ``get_object()`` calls
|
|
577
|
+
``check_object_permissions`` and every declared class's object check runs on the
|
|
578
|
+
resolved row. That is the same reasoning the route-table invariant already
|
|
579
|
+
encodes as ``ENFORCED_BY_VIEWSET``, kept identical here on purpose so the test
|
|
580
|
+
and the running code cannot disagree about which routes are covered.
|
|
581
|
+
|
|
582
|
+
Be honest about its limit: a ``detail=True`` ``@action`` that never calls
|
|
583
|
+
``get_object()`` is admitted by this and gated by nothing. That is unchanged
|
|
584
|
+
from today rather than introduced here — the fail-open this issue is about is
|
|
585
|
+
the ``detail=False`` half — but it is the residual hole, and it is why this
|
|
586
|
+
returns a narrow "a detail route exists" rather than claiming to prove a check
|
|
587
|
+
ran.
|
|
588
|
+
"""
|
|
589
|
+
if not isinstance(view, viewsets.ViewSetMixin):
|
|
590
|
+
return False
|
|
591
|
+
lookup = getattr(view, "lookup_url_kwarg", None) or getattr(view, "lookup_field", "pk")
|
|
592
|
+
if not isinstance(lookup, str):
|
|
593
|
+
return False
|
|
594
|
+
return lookup in (getattr(view, "kwargs", None) or {})
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _unresolved_scope_allows(request: Request, view: APIView, state: ScopeResolution) -> bool:
|
|
598
|
+
"""Verdict for a request whose project/program scope did not resolve (#3767).
|
|
599
|
+
|
|
600
|
+
This is the single place the family's default lives. It used to be a bare
|
|
601
|
+
``return True`` repeated in fourteen classes, which made the layer safe only as
|
|
602
|
+
a property of each call site: detail routes are covered by ``get_object()``,
|
|
603
|
+
list routes by ``ProjectScopedViewSet``'s membership-filtered queryset, and the
|
|
604
|
+
remaining ``detail=False`` writes by a hand-written in-body check. Nothing
|
|
605
|
+
stopped a *new* ``detail=False`` write action from silently joining that last
|
|
606
|
+
group without the check — the bug class ``TaskViewSet.delete_untouched_seeded``
|
|
607
|
+
documents in its own docstring.
|
|
608
|
+
|
|
609
|
+
Four outcomes, in order:
|
|
610
|
+
|
|
611
|
+
1. ``UNKNOWN_ID`` — allow. The view names the project, the id is simply not a
|
|
612
|
+
live one, and the view's own 404 is the right answer (#2745, #3129).
|
|
613
|
+
2. Safe methods — allow. Reads on an unscoped route are narrowed by the
|
|
614
|
+
viewset's membership-filtered queryset and by ``has_object_permission``;
|
|
615
|
+
denying them here would 403 every top-level list in the API.
|
|
616
|
+
3. A ViewSet detail route — allow. ``get_object()`` runs the object check.
|
|
617
|
+
4. Otherwise — **deny**, unless the view declares :data:`SCOPE_IN_BODY_ATTR`
|
|
618
|
+
with a reason. This is the flip: an unsafe write the layer cannot scope is
|
|
619
|
+
refused rather than waved through.
|
|
620
|
+
"""
|
|
621
|
+
if state is ScopeResolution.UNKNOWN_ID:
|
|
622
|
+
return True
|
|
623
|
+
if request.method in SAFE_METHODS:
|
|
624
|
+
return True
|
|
625
|
+
if _object_permission_will_run(view):
|
|
626
|
+
return True
|
|
627
|
+
return _scope_in_body_reason(view) is not None
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _resolve_project_scope(view: APIView) -> tuple[Any | None, ScopeResolution]:
|
|
631
|
+
""":func:`_project_pk_from_view`, with the reason the id is missing (#3767).
|
|
632
|
+
|
|
633
|
+
Same lookup, discriminated return. See :class:`ScopeResolution` for why the
|
|
634
|
+
three cases cannot share one ``None``.
|
|
635
|
+
"""
|
|
636
|
+
declared = getattr(view, "project_url_kwarg", None)
|
|
637
|
+
kwarg = declared if isinstance(declared, str) else DEFAULT_PROJECT_URL_KWARG
|
|
638
|
+
project_pk = getattr(view, "kwargs", {}).get(kwarg)
|
|
639
|
+
if project_pk is None:
|
|
640
|
+
return None, ScopeResolution.ABSENT
|
|
641
|
+
if isinstance(declared, str) and not _project_exists(project_pk):
|
|
642
|
+
return None, ScopeResolution.UNKNOWN_ID
|
|
643
|
+
return project_pk, ScopeResolution.RESOLVED
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _project_pk_from_view(view: APIView) -> Any | None:
|
|
647
|
+
"""Extract the project id from a view's URL kwargs.
|
|
648
|
+
|
|
649
|
+
Nested routes spell it ``project_pk`` (``/projects/<project_pk>/task-runs/``) and
|
|
650
|
+
resolve with no further ceremony. Routes that spell it differently declare the
|
|
651
|
+
name on the view::
|
|
652
|
+
|
|
653
|
+
class ProjectOverviewView(APIView):
|
|
654
|
+
project_url_kwarg = "pk" # route is projects/<pk>/overview/
|
|
655
|
+
|
|
656
|
+
**Why the declaration, rather than also trying ``pk`` (#2745).** ``pk`` names the
|
|
657
|
+
project on ``projects/<pk>/…`` and names something else entirely everywhere else —
|
|
658
|
+
a dependency on ``/dependencies/<pk>/``, a task on ``/tasks/<pk>/``. A blanket
|
|
659
|
+
alias would hand those ids to ``_membership_role``, which would find no membership
|
|
660
|
+
and **deny** every legitimate request on those routes. The failure mode of guessing
|
|
661
|
+
is worse than the fail-open it replaces: a silent no-op becomes a live outage. So
|
|
662
|
+
the kwarg is named by the view that knows, and by nothing else.
|
|
663
|
+
|
|
664
|
+
Returns None when the view declares no project kwarg and the route carries none —
|
|
665
|
+
a genuinely top-level route. Callers fall through to per-class handling (e.g.
|
|
666
|
+
``ProjectViewSet`` retrieves rely on ``ProjectScopedViewSet`` to filter the
|
|
667
|
+
queryset to member projects, and object-level checks run on detail routes).
|
|
668
|
+
|
|
669
|
+
**That None is no longer read as "allow" (#3767).** It still cannot tell an
|
|
670
|
+
unresolvable route from an intentionally top-level one — that is a property of
|
|
671
|
+
the URL, not of this function — so the callers now ask
|
|
672
|
+
:func:`_resolve_project_scope` for the *reason* and hand it to
|
|
673
|
+
:func:`_unresolved_scope_allows`, which denies an unsafe method the layer cannot
|
|
674
|
+
scope unless the view declares :data:`SCOPE_IN_BODY_ATTR`. This wrapper is kept
|
|
675
|
+
for the callers that only need the id.
|
|
676
|
+
|
|
677
|
+
Only a real string counts as a declaration. ``getattr`` on an object that
|
|
678
|
+
synthesizes attributes (a bare ``MagicMock`` view in a permission test is the
|
|
679
|
+
live example) hands back a truthy non-string, and ``kwargs.get(<that>)`` then
|
|
680
|
+
misses every key and returns None — which every caller used to read as "not
|
|
681
|
+
project-scoped" and fail OPEN. That is #2745's own defect re-entering through
|
|
682
|
+
its fix, so an unusable declaration is treated as no declaration.
|
|
683
|
+
|
|
684
|
+
What stops a route from silently rejoining the fail-open set is the route-table
|
|
685
|
+
invariant in ``tests/apps/access/test_route_table_invariants.py`` (#2772, #3767),
|
|
686
|
+
which asserts every project-identifying route enforces membership by *some* path
|
|
687
|
+
and names the ones that do it in the view body.
|
|
688
|
+
"""
|
|
689
|
+
return _resolve_project_scope(view)[0]
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class IsProjectMember(BasePermission):
|
|
693
|
+
"""Allow any project member (Viewer or above) to read; enforce membership on objects.
|
|
694
|
+
|
|
695
|
+
Project-nested routes (URL contains ``project_pk``): membership is enforced
|
|
696
|
+
in has_permission so list endpoints are gated before the queryset runs.
|
|
697
|
+
Top-level routes without ``project_pk``: authentication is sufficient at the
|
|
698
|
+
permission layer; per-object membership is enforced in has_object_permission.
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
message = "You must be a member of this project."
|
|
702
|
+
|
|
703
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
704
|
+
if not (request.user and request.user.is_authenticated):
|
|
705
|
+
return False
|
|
706
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
707
|
+
if project_pk is not None:
|
|
708
|
+
return _membership_role(request, project_pk) is not None
|
|
709
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
710
|
+
|
|
711
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
712
|
+
project_id = _get_project_id_from_obj(obj)
|
|
713
|
+
if project_id is None:
|
|
714
|
+
# Org-level object (Calendar) — authentication is sufficient.
|
|
715
|
+
return bool(request.user and request.user.is_authenticated)
|
|
716
|
+
return _membership_role(request, project_id) is not None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class IsProjectMemberWrite(BasePermission):
|
|
720
|
+
"""Allow Team Member (1) or above to perform write operations.
|
|
721
|
+
|
|
722
|
+
On safe methods falls back to IsProjectMember (Viewer+ may read).
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
message = "You need at least Team Member role to modify this project."
|
|
726
|
+
|
|
727
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
728
|
+
if not (request.user and request.user.is_authenticated):
|
|
729
|
+
return False
|
|
730
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
731
|
+
if project_pk is not None:
|
|
732
|
+
role = _membership_role(request, project_pk)
|
|
733
|
+
if role is None:
|
|
734
|
+
return False
|
|
735
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
736
|
+
return True
|
|
737
|
+
return role >= Role.MEMBER
|
|
738
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
739
|
+
|
|
740
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
741
|
+
project_id = _get_project_id_from_obj(obj)
|
|
742
|
+
if project_id is None:
|
|
743
|
+
return False
|
|
744
|
+
role = _membership_role(request, project_id)
|
|
745
|
+
if role is None:
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
safe = request.method in ("GET", "HEAD", "OPTIONS")
|
|
749
|
+
if safe:
|
|
750
|
+
return True
|
|
751
|
+
return role >= Role.MEMBER
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
class IsProjectPlanAuthor(BasePermission):
|
|
755
|
+
"""Declarative wrapper on :func:`can_user_author_plan` (ADR-0773 §(b)).
|
|
756
|
+
|
|
757
|
+
Sits *alongside* ``IsProjectMemberWrite`` on the task-authoring endpoints rather
|
|
758
|
+
than replacing it, per ADR-0184's additive doctrine: the permission class is
|
|
759
|
+
defense-in-depth and OpenAPI-visible, while the in-body per-row checks
|
|
760
|
+
(``can_user_edit_task``, ``_require_wbs_restructure_permission``) stay
|
|
761
|
+
authoritative for *which rows* a caller may touch.
|
|
762
|
+
|
|
763
|
+
Safe methods fall through to the read gate — Read mode is open to every project
|
|
764
|
+
member including Viewer, so only writes consult the authoring predicate.
|
|
765
|
+
|
|
766
|
+
Note this class is currently defense-in-depth rather than the load-bearing gate
|
|
767
|
+
on ``<pk>``-routed ``APIView``s: ``_project_pk_from_view`` reads only
|
|
768
|
+
``project_pk``, so ``has_permission`` cannot resolve the project on a
|
|
769
|
+
``projects/<pk>/...`` route and falls through (#2745). The in-body
|
|
770
|
+
``check_object_permissions(request, project)`` call is what actually enforces
|
|
771
|
+
this today, which is why the authoring views call it explicitly.
|
|
772
|
+
"""
|
|
773
|
+
|
|
774
|
+
message = "Your role on this project cannot author the plan."
|
|
775
|
+
|
|
776
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
777
|
+
if not (request.user and request.user.is_authenticated):
|
|
778
|
+
return False
|
|
779
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
780
|
+
return True
|
|
781
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
782
|
+
if project_pk is None:
|
|
783
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
784
|
+
return _can_author_plan_for_project_id(request, project_pk)
|
|
785
|
+
|
|
786
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
787
|
+
project_id = _get_project_id_from_obj(obj)
|
|
788
|
+
if project_id is None:
|
|
789
|
+
return False
|
|
790
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
791
|
+
return _membership_role(request, project_id) is not None
|
|
792
|
+
return _can_author_plan_for_project_id(request, project_id)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
class IsProjectMemberWriteOrOwn(BasePermission):
|
|
796
|
+
"""Assignee-scoped write permission for TaskViewSet update/destroy actions.
|
|
797
|
+
|
|
798
|
+
Role matrix (issue #11; ordinals per ADR-0072, VIEWER moved to 1 in #2489):
|
|
799
|
+
Viewer (1) — read only
|
|
800
|
+
Team Member (100) — edit tasks where task.assignee == request.user
|
|
801
|
+
Resource Manager (200) — read only (cannot edit task content, only assign)
|
|
802
|
+
Project Manager (300+) — edit any task
|
|
803
|
+
|
|
804
|
+
Safe methods (GET/HEAD/OPTIONS) allow any project member (Viewer+).
|
|
805
|
+
|
|
806
|
+
Unassigned tasks (assignee=None) may only be edited by Project Manager+;
|
|
807
|
+
a Team Member cannot claim or edit a task that has no assignee yet.
|
|
808
|
+
"""
|
|
809
|
+
|
|
810
|
+
message = "You do not have permission to edit this task."
|
|
811
|
+
|
|
812
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
813
|
+
return bool(request.user and request.user.is_authenticated)
|
|
814
|
+
|
|
815
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
816
|
+
project_id = _get_project_id_from_obj(obj)
|
|
817
|
+
if project_id is None:
|
|
818
|
+
return False
|
|
819
|
+
if _membership_role(request, project_id) is None:
|
|
820
|
+
return False
|
|
821
|
+
|
|
822
|
+
# Safe methods: any project member may read
|
|
823
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
824
|
+
return True
|
|
825
|
+
|
|
826
|
+
# Delegate the write decision to the shared predicate (ADR-0133) so the
|
|
827
|
+
# rule the serializer's can_edit/can_delete fields declare is the exact
|
|
828
|
+
# rule enforced here — one rule, called twice, can never drift. The PO
|
|
829
|
+
# facet, Scheduler-read-only, Member-own-only, and Admin+ branches all
|
|
830
|
+
# live in can_user_edit_task now.
|
|
831
|
+
#
|
|
832
|
+
# The task-restore action (#2078) is a POST but must gate exactly like
|
|
833
|
+
# DELETE — un-deleting is a delete-class act, so the PO grooming facet
|
|
834
|
+
# (which may edit but not delete an EPIC/STORY) must NOT grant restore.
|
|
835
|
+
# Pass DELETE semantics so restore parity with destroy is exact.
|
|
836
|
+
method = (
|
|
837
|
+
"DELETE" if getattr(view, "action", None) == "restore" else (request.method or "PATCH")
|
|
838
|
+
)
|
|
839
|
+
return can_user_edit_task(request, obj, method=method)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
class CanLogTime(BasePermission):
|
|
843
|
+
"""Gate time-entry writes: role >= MEMBER on the task's project (ADR-0185 §3).
|
|
844
|
+
|
|
845
|
+
Object-level by design. ``has_permission`` only verifies authentication; the
|
|
846
|
+
authoritative role check is ``has_object_permission``, invoked by the view via
|
|
847
|
+
``check_object_permissions(task)`` **after** the view has resolved the task against
|
|
848
|
+
a membership-scoped queryset. Doing the role check in ``has_permission`` would 403 a
|
|
849
|
+
cross-project task that must instead 404 (the task is resolved member-scoped, so a
|
|
850
|
+
non-member sees a 404 existence-oracle close, not a 403). A Viewer *is* a member, so
|
|
851
|
+
their task resolves and this object check then yields the 403.
|
|
852
|
+
|
|
853
|
+
Read methods only require membership (a Viewer may read their own, possibly empty,
|
|
854
|
+
entries); unsafe methods require Member+ via :func:`can_user_log_time`.
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
message = "You need at least Team Member role to log time on this task."
|
|
858
|
+
|
|
859
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
860
|
+
return bool(request.user and request.user.is_authenticated)
|
|
861
|
+
|
|
862
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
863
|
+
# ``obj`` is the resolved Task (the time entry's subject), or an object with a
|
|
864
|
+
# ``task`` FK. Resolve to the task either way.
|
|
865
|
+
task = obj if obj.__class__.__name__ == "Task" else getattr(obj, "task", None)
|
|
866
|
+
if task is None:
|
|
867
|
+
return False
|
|
868
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
869
|
+
project_id = getattr(task, "project_id", None)
|
|
870
|
+
return project_id is not None and _membership_role(request, project_id) is not None
|
|
871
|
+
return can_user_log_time(request, task)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
class IsProjectScheduler(BasePermission):
|
|
875
|
+
"""Allow Resource Manager (2) or above.
|
|
876
|
+
|
|
877
|
+
Used on: dependency creation/edit.
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
message = "You need at least Resource Manager role for this action."
|
|
881
|
+
|
|
882
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
883
|
+
if not (request.user and request.user.is_authenticated):
|
|
884
|
+
return False
|
|
885
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
886
|
+
if project_pk is not None:
|
|
887
|
+
role = _membership_role(request, project_pk)
|
|
888
|
+
return role is not None and role >= Role.SCHEDULER
|
|
889
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
890
|
+
|
|
891
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
892
|
+
project_id = _get_project_id_from_obj(obj)
|
|
893
|
+
if project_id is None:
|
|
894
|
+
return False
|
|
895
|
+
role = _membership_role(request, project_id)
|
|
896
|
+
return role is not None and role >= Role.SCHEDULER
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
class IsProjectAdmin(BasePermission):
|
|
900
|
+
"""Allow Project Manager (3) or above."""
|
|
901
|
+
|
|
902
|
+
message = "You need at least Project Manager role for this action."
|
|
903
|
+
|
|
904
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
905
|
+
if not (request.user and request.user.is_authenticated):
|
|
906
|
+
return False
|
|
907
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
908
|
+
if project_pk is not None:
|
|
909
|
+
role = _membership_role(request, project_pk)
|
|
910
|
+
return role is not None and role >= Role.ADMIN
|
|
911
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
912
|
+
|
|
913
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
914
|
+
project_id = _get_project_id_from_obj(obj)
|
|
915
|
+
if project_id is None:
|
|
916
|
+
return False
|
|
917
|
+
role = _membership_role(request, project_id)
|
|
918
|
+
return role is not None and role >= Role.ADMIN
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def can_manage_backlog(role: int | None) -> bool:
|
|
922
|
+
"""Whether ``role`` may perform structural product-backlog actions (ADR-0105).
|
|
923
|
+
|
|
924
|
+
Structural = auto-rank, scoring-model / auto-rank toggle, epic create/delete,
|
|
925
|
+
priority reorder. Maps to Admin+ today. This is the role half of the gate;
|
|
926
|
+
the facet half lives in :func:`can_manage_backlog_with_facet`. Story-field
|
|
927
|
+
grooming (AC, dor, points, scoring inputs on a story) is NOT gated here — that
|
|
928
|
+
rides the normal Member+ task-write permission so contributors can refine their
|
|
929
|
+
own stories.
|
|
930
|
+
"""
|
|
931
|
+
return role is not None and role >= Role.ADMIN
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def can_manage_backlog_with_facet(user: Any, project_id: Any, role: int | None) -> bool:
|
|
935
|
+
"""Whether ``user`` may perform structural product-backlog actions (ADR-0078/#1095).
|
|
936
|
+
|
|
937
|
+
Admin+ OR the Product Owner facet. The PO facet (ADR-0078 two-axis RBAC, #927)
|
|
938
|
+
grants backlog management without requiring an Admin role bump, so a Product
|
|
939
|
+
Owner who is a project Member can still reorder + auto-rank the backlog. The
|
|
940
|
+
facet lookup is imported lazily to avoid an access ↔ teams import cycle
|
|
941
|
+
(teams.permissions already imports from access.permissions).
|
|
942
|
+
|
|
943
|
+
The facet arm is a *widening* of the role arm, never a substitute for project
|
|
944
|
+
access: ``has_team_facet`` carries a live-``ProjectMembership`` floor (#3386),
|
|
945
|
+
so a revoked member's residual mirrored ``TeamMembership`` — which the
|
|
946
|
+
create-only ADR-0078 §F mirror leaves behind with its flags intact — cannot
|
|
947
|
+
become their only credential on the branch that runs precisely when ``role``
|
|
948
|
+
is ``None``.
|
|
949
|
+
"""
|
|
950
|
+
if can_manage_backlog(role):
|
|
951
|
+
return True
|
|
952
|
+
from trueppm_api.apps.teams.services import has_team_facet
|
|
953
|
+
|
|
954
|
+
return has_team_facet(user, project_id, "is_product_owner")
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
class IsProjectBacklogManager(BasePermission):
|
|
958
|
+
"""Gate structural product-backlog actions (ADR-0105).
|
|
959
|
+
|
|
960
|
+
Admin+ OR Product Owner facet (ADR-0078/#1095) — see
|
|
961
|
+
:func:`can_manage_backlog_with_facet`.
|
|
962
|
+
"""
|
|
963
|
+
|
|
964
|
+
message = (
|
|
965
|
+
"You need at least Project Manager role or the Product Owner facet "
|
|
966
|
+
"to manage the product backlog."
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
970
|
+
if not (request.user and request.user.is_authenticated):
|
|
971
|
+
return False
|
|
972
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
973
|
+
if project_pk is not None:
|
|
974
|
+
return can_manage_backlog_with_facet(
|
|
975
|
+
request.user, project_pk, _membership_role(request, project_pk)
|
|
976
|
+
)
|
|
977
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
978
|
+
|
|
979
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
980
|
+
project_id = _get_project_id_from_obj(obj)
|
|
981
|
+
if project_id is None:
|
|
982
|
+
return False
|
|
983
|
+
return can_manage_backlog_with_facet(
|
|
984
|
+
request.user, project_id, _membership_role(request, project_id)
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def can_manage_scope_with_facet(user: Any, project_id: Any, role: int | None) -> bool:
|
|
989
|
+
"""Whether ``user`` may accept/reject sprint scope injections (ADR-0102 §3, ADR-0123 §3).
|
|
990
|
+
|
|
991
|
+
Admin+ OR the Scrum Master / Product Owner facet (ADR-0078, #1140). The PO owns
|
|
992
|
+
sprint scope and the SM facilitates the ceremony, so each facet grants the
|
|
993
|
+
accept/reject gate without an Admin role bump — mirroring how the Product Owner
|
|
994
|
+
facet widens the backlog gate (:func:`can_manage_backlog_with_facet`), but
|
|
995
|
+
honoring **both** facets here because both run the sprint ceremony.
|
|
996
|
+
|
|
997
|
+
The facet lookup resolves to a real, non-soft-deleted default-team
|
|
998
|
+
``TeamMembership`` row **whose user still holds a live ``ProjectMembership``**
|
|
999
|
+
(#3386) — preserving the ADR-0102 §3 back-door close: an org/PMO principal has
|
|
1000
|
+
neither an Admin ``ProjectMembership`` nor a team facet and is denied regardless
|
|
1001
|
+
of any role ordinal, and a member whose project access was revoked loses the
|
|
1002
|
+
facet arm with it rather than keeping a residual mirrored team row as their only
|
|
1003
|
+
credential. Imported lazily to avoid the access ↔ teams import cycle.
|
|
1004
|
+
"""
|
|
1005
|
+
if role is not None and role >= Role.ADMIN:
|
|
1006
|
+
return True
|
|
1007
|
+
from trueppm_api.apps.teams.services import user_facets
|
|
1008
|
+
|
|
1009
|
+
facets = user_facets(user, project_id)
|
|
1010
|
+
return facets["is_scrum_master"] or facets["is_product_owner"]
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class IsProjectScopeManager(BasePermission):
|
|
1014
|
+
"""Gate sprint scope-injection accept/reject (ADR-0102 §3, widened by ADR-0123 §3).
|
|
1015
|
+
|
|
1016
|
+
Admin+ OR the Scrum Master / Product Owner facet (ADR-0078, #1140) — see
|
|
1017
|
+
:func:`can_manage_scope_with_facet`. The matching service-layer gate
|
|
1018
|
+
(``assert_scope_gate_for_project``) re-enforces the same rule so the boundary
|
|
1019
|
+
holds even if a view forgets this class.
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
message = (
|
|
1023
|
+
"You need at least Project Manager role or the Scrum Master / "
|
|
1024
|
+
"Product Owner facet to accept or reject sprint scope changes."
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1028
|
+
if not (request.user and request.user.is_authenticated):
|
|
1029
|
+
return False
|
|
1030
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
1031
|
+
if project_pk is not None:
|
|
1032
|
+
return can_manage_scope_with_facet(
|
|
1033
|
+
request.user, project_pk, _membership_role(request, project_pk)
|
|
1034
|
+
)
|
|
1035
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1036
|
+
|
|
1037
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1038
|
+
project_id = _get_project_id_from_obj(obj)
|
|
1039
|
+
if project_id is None:
|
|
1040
|
+
return False
|
|
1041
|
+
return can_manage_scope_with_facet(
|
|
1042
|
+
request.user, project_id, _membership_role(request, project_id)
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
class IsTaskScopeManager(BasePermission):
|
|
1047
|
+
"""Scope-manager gate for objects reached through a ``task`` FK (#1351).
|
|
1048
|
+
|
|
1049
|
+
Mirrors :class:`IsProjectScopeManager` (Admin+ OR the Scrum Master / Product
|
|
1050
|
+
Owner facet, ADR-0102 §3) but resolves the project through ``obj.task`` rather
|
|
1051
|
+
than ``obj.project`` — :func:`_get_project_id_from_obj` cannot follow a ``task``
|
|
1052
|
+
hop, so a generic scope-manager class would deny everyone on these objects.
|
|
1053
|
+
Used by ``CrossProjectSlipConflictViewSet.acknowledge`` as the permission-layer
|
|
1054
|
+
expression of its in-body gate; the in-body check stays for defense-in-depth,
|
|
1055
|
+
so the boundary holds even if a view forgets this class. Read methods are not
|
|
1056
|
+
this class's concern — it is only attached to the unsafe acknowledge action.
|
|
1057
|
+
"""
|
|
1058
|
+
|
|
1059
|
+
message = (
|
|
1060
|
+
"You need Admin or the Scrum Master / Product Owner facet on this "
|
|
1061
|
+
"project to perform this action."
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1065
|
+
# No project_pk in the top-level slip-conflict route; authorization is an
|
|
1066
|
+
# object-level decision resolved once get_object() runs.
|
|
1067
|
+
return bool(request.user and request.user.is_authenticated)
|
|
1068
|
+
|
|
1069
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1070
|
+
task = getattr(obj, "task", None)
|
|
1071
|
+
project_id = getattr(task, "project_id", None)
|
|
1072
|
+
if project_id is None:
|
|
1073
|
+
return False
|
|
1074
|
+
return can_manage_scope_with_facet(
|
|
1075
|
+
request.user, project_id, _membership_role(request, project_id)
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
class IsProjectOwner(BasePermission):
|
|
1080
|
+
"""Allow only Project Admin (Owner, 4).
|
|
1081
|
+
|
|
1082
|
+
Used for: ProjectViewSet.destroy (only Project Admin may delete a project).
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
message = "Only the Project Admin can perform this action."
|
|
1086
|
+
|
|
1087
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1088
|
+
if not (request.user and request.user.is_authenticated):
|
|
1089
|
+
return False
|
|
1090
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
1091
|
+
if project_pk is not None:
|
|
1092
|
+
role = _membership_role(request, project_pk)
|
|
1093
|
+
return role == Role.OWNER
|
|
1094
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1095
|
+
|
|
1096
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1097
|
+
project_id = _get_project_id_from_obj(obj)
|
|
1098
|
+
if project_id is None:
|
|
1099
|
+
return False
|
|
1100
|
+
role = _membership_role(request, project_id)
|
|
1101
|
+
return role == Role.OWNER
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
# ---------------------------------------------------------------------------
|
|
1105
|
+
# Program permission helpers (ADR-0070)
|
|
1106
|
+
# ---------------------------------------------------------------------------
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _program_pk_from_view(view: APIView) -> Any | None:
|
|
1110
|
+
"""Extract ``program_pk`` from a view's URL kwargs (nested program routes).
|
|
1111
|
+
|
|
1112
|
+
Program-nested routes use ``program_pk`` (e.g. /programs/<program_pk>/members/).
|
|
1113
|
+
Top-level program routes use ``pk``; that case is handled by individual
|
|
1114
|
+
permission classes that fall through to per-object checks — see
|
|
1115
|
+
:func:`_unresolved_scope_allows` for what "fall through" now means on a write.
|
|
1116
|
+
"""
|
|
1117
|
+
return _resolve_program_scope(view)[0]
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _resolve_program_scope(view: APIView) -> tuple[Any | None, ScopeResolution]:
|
|
1121
|
+
""":func:`_program_pk_from_view`, with the reason the id is missing (#3767).
|
|
1122
|
+
|
|
1123
|
+
There is no ``UNKNOWN_ID`` arm here, and that asymmetry with
|
|
1124
|
+
:func:`_resolve_project_scope` is deliberate rather than an omission. The
|
|
1125
|
+
project resolver stands down on an unknown id because #2745 gave ``<pk>``-routed
|
|
1126
|
+
project views a ``project_url_kwarg`` declaration and had to keep their existing
|
|
1127
|
+
404 (#3129); no program route declares an alias, so a program id either arrives
|
|
1128
|
+
as ``program_pk`` and is checked, or the route names no program at all.
|
|
1129
|
+
"""
|
|
1130
|
+
program_pk = getattr(view, "kwargs", {}).get("program_pk")
|
|
1131
|
+
if program_pk is None:
|
|
1132
|
+
return None, ScopeResolution.ABSENT
|
|
1133
|
+
return program_pk, ScopeResolution.RESOLVED
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _program_membership_role(request: Request, program_id: Any) -> int | None:
|
|
1137
|
+
"""Return the requesting user's role ordinal for a program, or None if absent.
|
|
1138
|
+
|
|
1139
|
+
Mirrors :func:`_membership_role` for ``ProgramMembership``. Per-request cache
|
|
1140
|
+
is keyed separately (``_program_rbac_role_cache``) so program and project
|
|
1141
|
+
membership lookups don't collide.
|
|
1142
|
+
"""
|
|
1143
|
+
# Import here to avoid the module-level circular import (access ↔ access).
|
|
1144
|
+
from trueppm_api.apps.access.models import ProgramMembership
|
|
1145
|
+
|
|
1146
|
+
if not request.user or not request.user.is_authenticated:
|
|
1147
|
+
return None
|
|
1148
|
+
|
|
1149
|
+
host = _cache_host(request)
|
|
1150
|
+
cache: dict[str, int | None] | None = getattr(host, PROGRAM_RBAC_ROLE_CACHE_ATTR, None)
|
|
1151
|
+
if cache is None:
|
|
1152
|
+
cache = {}
|
|
1153
|
+
setattr(host, PROGRAM_RBAC_ROLE_CACHE_ATTR, cache)
|
|
1154
|
+
|
|
1155
|
+
cache_key = str(program_id)
|
|
1156
|
+
if cache_key in cache:
|
|
1157
|
+
return cache[cache_key]
|
|
1158
|
+
|
|
1159
|
+
try:
|
|
1160
|
+
membership = ProgramMembership.live().get(
|
|
1161
|
+
program_id=program_id,
|
|
1162
|
+
user=request.user,
|
|
1163
|
+
)
|
|
1164
|
+
role: int | None = membership.role
|
|
1165
|
+
except ProgramMembership.DoesNotExist:
|
|
1166
|
+
role = None
|
|
1167
|
+
|
|
1168
|
+
cache[cache_key] = role
|
|
1169
|
+
return role
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def effective_project_role(request: Request, project_id: Any) -> int | None:
|
|
1173
|
+
"""Public, request-cached lookup of the caller's role ordinal on a project.
|
|
1174
|
+
|
|
1175
|
+
Thin wrapper over the internal :func:`_membership_role` so callers outside
|
|
1176
|
+
this module (e.g. the cross-project dependency consent gate in ADR-0120 D2)
|
|
1177
|
+
have a documented surface for "what role does the requester hold on project
|
|
1178
|
+
X" without importing a private helper. Returns ``None`` when the user has no
|
|
1179
|
+
active membership. Compare against :class:`~trueppm_api.apps.access.models.Role`
|
|
1180
|
+
ordinals (``>= Role.SCHEDULER`` for schedule authority).
|
|
1181
|
+
"""
|
|
1182
|
+
return _membership_role(request, project_id)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def effective_program_role(request: Request, program_id: Any) -> int | None:
|
|
1186
|
+
"""Public, request-cached lookup of the caller's role ordinal on a program.
|
|
1187
|
+
|
|
1188
|
+
Wrapper over :func:`_program_membership_role`. Used by the ADR-0120 minimal
|
|
1189
|
+
visibility card / consent gate to grant *read* access to a cross-edge
|
|
1190
|
+
counterpart task: a ``ProgramMembership`` holder may read either member
|
|
1191
|
+
project's task card even without a direct ``ProjectMembership``.
|
|
1192
|
+
"""
|
|
1193
|
+
return _program_membership_role(request, program_id)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _get_program_id_from_obj(obj: Any) -> Any | None:
|
|
1197
|
+
"""Extract a program PK from a model instance for has_object_permission checks.
|
|
1198
|
+
|
|
1199
|
+
Supports direct Program instances and any model with a ``program_id``
|
|
1200
|
+
attribute (Project — via the new FK — and ProgramMembership).
|
|
1201
|
+
"""
|
|
1202
|
+
from trueppm_api.apps.projects.models import Program
|
|
1203
|
+
|
|
1204
|
+
if isinstance(obj, Program):
|
|
1205
|
+
return obj.pk
|
|
1206
|
+
if hasattr(obj, "program_id"):
|
|
1207
|
+
return obj.program_id
|
|
1208
|
+
if hasattr(obj, "program"):
|
|
1209
|
+
return obj.program_id
|
|
1210
|
+
return None
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
class IsProgramMember(BasePermission):
|
|
1214
|
+
"""Allow any program member (Viewer+) to read; enforce membership on objects.
|
|
1215
|
+
|
|
1216
|
+
Program-nested routes (URL contains ``program_pk``): membership is enforced
|
|
1217
|
+
in ``has_permission`` so list endpoints are gated before the queryset runs.
|
|
1218
|
+
Top-level routes without ``program_pk`` rely on per-object checks.
|
|
1219
|
+
"""
|
|
1220
|
+
|
|
1221
|
+
message = "You must be a member of this program."
|
|
1222
|
+
|
|
1223
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1224
|
+
if not (request.user and request.user.is_authenticated):
|
|
1225
|
+
return False
|
|
1226
|
+
program_pk, scope = _resolve_program_scope(view)
|
|
1227
|
+
if program_pk is not None:
|
|
1228
|
+
return _program_membership_role(request, program_pk) is not None
|
|
1229
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1230
|
+
|
|
1231
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1232
|
+
program_id = _get_program_id_from_obj(obj)
|
|
1233
|
+
if program_id is None:
|
|
1234
|
+
return False
|
|
1235
|
+
return _program_membership_role(request, program_id) is not None
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
class IsProgramScheduler(BasePermission):
|
|
1239
|
+
"""Require Scheduler (2) or above on a program — for **reads** as well as writes.
|
|
1240
|
+
|
|
1241
|
+
The program counterpart to ``IsProjectScheduler``. Resource allocation /
|
|
1242
|
+
contention data is Scheduler+ even on GET (web-rule 94 / the per-project
|
|
1243
|
+
``resource-allocation`` gate), so unlike ``IsProgramEditor`` this does **not**
|
|
1244
|
+
open GET to every member — a Viewer or plain Member is denied 403.
|
|
1245
|
+
"""
|
|
1246
|
+
|
|
1247
|
+
# "Resource Manager" is the label for ``Role.SCHEDULER`` in both vocabularies;
|
|
1248
|
+
# "Scheduler" is the code name and appears on no surface, so a refusal naming
|
|
1249
|
+
# it sends the reader looking for a role that does not exist (#3503). Matches
|
|
1250
|
+
# ``IsProjectScheduler.message``, which already says Resource Manager.
|
|
1251
|
+
message = "You need at least Resource Manager role on this program."
|
|
1252
|
+
|
|
1253
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1254
|
+
if not (request.user and request.user.is_authenticated):
|
|
1255
|
+
return False
|
|
1256
|
+
program_pk, scope = _resolve_program_scope(view)
|
|
1257
|
+
if program_pk is not None:
|
|
1258
|
+
role = _program_membership_role(request, program_pk)
|
|
1259
|
+
return role is not None and role >= Role.SCHEDULER
|
|
1260
|
+
# Top-level routes (e.g. /programs/{pk}/…) carry no program_pk kwarg;
|
|
1261
|
+
# defer to the per-object check, which get_object() triggers.
|
|
1262
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1263
|
+
|
|
1264
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1265
|
+
program_id = _get_program_id_from_obj(obj)
|
|
1266
|
+
if program_id is None:
|
|
1267
|
+
return False
|
|
1268
|
+
role = _program_membership_role(request, program_id)
|
|
1269
|
+
return role is not None and role >= Role.SCHEDULER
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
class IsProgramEditor(BasePermission):
|
|
1273
|
+
"""Allow Team Member (1) or above on a program.
|
|
1274
|
+
|
|
1275
|
+
Used for BacklogItem create/edit endpoints (#501) and any other program-
|
|
1276
|
+
level write that is not Admin-gated. For #502 the only Editor-gated action
|
|
1277
|
+
is project add/remove on the program — that's gated by IsProgramAdmin
|
|
1278
|
+
because it changes program membership-adjacent state. Editor is exposed
|
|
1279
|
+
here for #501 to reuse.
|
|
1280
|
+
"""
|
|
1281
|
+
|
|
1282
|
+
message = "You need at least Team Member role on this program."
|
|
1283
|
+
|
|
1284
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1285
|
+
if not (request.user and request.user.is_authenticated):
|
|
1286
|
+
return False
|
|
1287
|
+
program_pk, scope = _resolve_program_scope(view)
|
|
1288
|
+
if program_pk is not None:
|
|
1289
|
+
role = _program_membership_role(request, program_pk)
|
|
1290
|
+
if role is None:
|
|
1291
|
+
return False
|
|
1292
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1293
|
+
return True
|
|
1294
|
+
return role >= Role.MEMBER
|
|
1295
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1296
|
+
|
|
1297
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1298
|
+
program_id = _get_program_id_from_obj(obj)
|
|
1299
|
+
if program_id is None:
|
|
1300
|
+
return False
|
|
1301
|
+
role = _program_membership_role(request, program_id)
|
|
1302
|
+
if role is None:
|
|
1303
|
+
return False
|
|
1304
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1305
|
+
return True
|
|
1306
|
+
return role >= Role.MEMBER
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
class IsProgramAdmin(BasePermission):
|
|
1310
|
+
"""Allow Program Manager (``Role.ADMIN``, 300) or above on a program.
|
|
1311
|
+
|
|
1312
|
+
Used for: updating program metadata, adding/removing projects from the
|
|
1313
|
+
program, managing membership.
|
|
1314
|
+
|
|
1315
|
+
The tier is named in **program** vocabulary because this class only ever
|
|
1316
|
+
refuses on a program surface, and a refusal that names a role the surface
|
|
1317
|
+
does not offer is unactionable (#3503). It is the same ordinal a project
|
|
1318
|
+
calls "Project Manager".
|
|
1319
|
+
"""
|
|
1320
|
+
|
|
1321
|
+
message = "You need at least Program Manager role on this program."
|
|
1322
|
+
|
|
1323
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1324
|
+
if not (request.user and request.user.is_authenticated):
|
|
1325
|
+
return False
|
|
1326
|
+
program_pk, scope = _resolve_program_scope(view)
|
|
1327
|
+
if program_pk is not None:
|
|
1328
|
+
role = _program_membership_role(request, program_pk)
|
|
1329
|
+
return role is not None and role >= Role.ADMIN
|
|
1330
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1331
|
+
|
|
1332
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1333
|
+
program_id = _get_program_id_from_obj(obj)
|
|
1334
|
+
if program_id is None:
|
|
1335
|
+
return False
|
|
1336
|
+
role = _program_membership_role(request, program_id)
|
|
1337
|
+
return role is not None and role >= Role.ADMIN
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
class IsProgramOwner(BasePermission):
|
|
1341
|
+
"""Allow only Program Admin (``Role.OWNER``, 400). Used for: program delete.
|
|
1342
|
+
|
|
1343
|
+
"Program Admin" is the program label for the ordinal ``OWNER``; "Owner" is
|
|
1344
|
+
the code name, not a role a user is shown anywhere (#3503).
|
|
1345
|
+
"""
|
|
1346
|
+
|
|
1347
|
+
message = "Only the Program Admin can perform this action."
|
|
1348
|
+
|
|
1349
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1350
|
+
if not (request.user and request.user.is_authenticated):
|
|
1351
|
+
return False
|
|
1352
|
+
program_pk, scope = _resolve_program_scope(view)
|
|
1353
|
+
if program_pk is not None:
|
|
1354
|
+
role = _program_membership_role(request, program_pk)
|
|
1355
|
+
return role == Role.OWNER
|
|
1356
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1357
|
+
|
|
1358
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1359
|
+
program_id = _get_program_id_from_obj(obj)
|
|
1360
|
+
if program_id is None:
|
|
1361
|
+
return False
|
|
1362
|
+
role = _program_membership_role(request, program_id)
|
|
1363
|
+
return role == Role.OWNER
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _is_project_archived(request: Request, project_id: Any) -> bool:
|
|
1367
|
+
"""Per-request cache for ``Project.is_archived`` lookups (#530).
|
|
1368
|
+
|
|
1369
|
+
Mirrors the cache pattern used by :func:`_membership_role` (L1 fix). Nested
|
|
1370
|
+
write requests (bulk task update, dependency create-many, etc.) trigger
|
|
1371
|
+
has_permission + has_object_permission per object — without a cache the
|
|
1372
|
+
archived-state .exists() query would run N+1 times per request.
|
|
1373
|
+
"""
|
|
1374
|
+
from trueppm_api.apps.projects.models import Project
|
|
1375
|
+
|
|
1376
|
+
cache: dict[str, bool] | None = getattr(request, "_project_archive_cache", None)
|
|
1377
|
+
if cache is None:
|
|
1378
|
+
cache = {}
|
|
1379
|
+
request._project_archive_cache = cache # type: ignore[attr-defined]
|
|
1380
|
+
key = str(project_id)
|
|
1381
|
+
if key not in cache:
|
|
1382
|
+
cache[key] = Project.objects.filter(pk=project_id, is_archived=True).exists()
|
|
1383
|
+
return cache[key]
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _is_program_closed(request: Request, program_id: Any) -> bool:
|
|
1387
|
+
"""Per-request cache for ``Program.is_closed`` lookups (#530)."""
|
|
1388
|
+
from trueppm_api.apps.projects.models import Program
|
|
1389
|
+
|
|
1390
|
+
cache: dict[str, bool] | None = getattr(request, "_program_close_cache", None)
|
|
1391
|
+
if cache is None:
|
|
1392
|
+
cache = {}
|
|
1393
|
+
request._program_close_cache = cache # type: ignore[attr-defined]
|
|
1394
|
+
key = str(program_id)
|
|
1395
|
+
if key not in cache:
|
|
1396
|
+
cache[key] = Program.objects.filter(pk=program_id, is_closed=True).exists()
|
|
1397
|
+
return cache[key]
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
class IsProjectNotArchived(BasePermission):
|
|
1401
|
+
"""Block writes to projects flagged ``is_archived=True`` (#530).
|
|
1402
|
+
|
|
1403
|
+
Archived projects are hard read-only — every write across tasks, deps,
|
|
1404
|
+
members, settings, and nested resources must fail. Reads (SAFE_METHODS)
|
|
1405
|
+
always pass; the ``POST /projects/<pk>/unarchive/`` action is the explicit
|
|
1406
|
+
exception so an Owner can restore writes without first un-archiving via
|
|
1407
|
+
a back-channel.
|
|
1408
|
+
|
|
1409
|
+
Apply alongside the existing role permission (``IsProjectMemberWrite``,
|
|
1410
|
+
``IsProjectAdmin``, etc.) on every write-capable viewset — this class
|
|
1411
|
+
enforces lifecycle state, not authority.
|
|
1412
|
+
"""
|
|
1413
|
+
|
|
1414
|
+
message = "This project is archived and cannot be modified. Unarchive it first."
|
|
1415
|
+
|
|
1416
|
+
# Action names that must bypass the archived check on the PROJECT ROW ITSELF —
|
|
1417
|
+
# otherwise an Owner could never unarchive (catch-22), delete, or restore it.
|
|
1418
|
+
#
|
|
1419
|
+
# **Scoped by viewset class, not by action name alone (#3414).** It used to match
|
|
1420
|
+
# the name only, with a comment saying to scope it "before" a second viewset named
|
|
1421
|
+
# one of these actions. That moment had already passed: `destroy` is a router-minted
|
|
1422
|
+
# action on every ModelViewSet, and by 0.4 twenty project-scoped viewsets carried
|
|
1423
|
+
# this class AND exposed `destroy` — tasks, risks, dependencies, comments,
|
|
1424
|
+
# attachments, notes, links, labels, phases, baselines, memberships, custom fields,
|
|
1425
|
+
# api-tokens, project/task resources, mention groups, sprints, retro items — plus
|
|
1426
|
+
# `TaskViewSet.restore`. Every one of those DELETEs answered 204 on an archived
|
|
1427
|
+
# project, because the bypass fires in `has_permission` AND `has_object_permission`,
|
|
1428
|
+
# so nothing downstream re-checked. The invariant that was supposed to catch this
|
|
1429
|
+
# (`test_no_viewset_but_projectviewset_exposes_an_archive_bypass_action`) read the
|
|
1430
|
+
# action map off `initkwargs`, where DRF never puts it — it had been asserting over
|
|
1431
|
+
# an empty set since the day it was written.
|
|
1432
|
+
#
|
|
1433
|
+
# `_bypasses_archive_check` therefore requires the view to BE the project viewset.
|
|
1434
|
+
# Anything else — a `destroy` on a task, a `restore` on a resource — goes through
|
|
1435
|
+
# the archived check like any other write.
|
|
1436
|
+
_ARCHIVE_BYPASS_ACTIONS: frozenset[str] = frozenset(
|
|
1437
|
+
{"unarchive", "destroy", "archive", "restore"}
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
@staticmethod
|
|
1441
|
+
def _bypasses_archive_check(view: APIView) -> bool:
|
|
1442
|
+
"""Is this the project-lifecycle escape hatch, rather than a same-named action?
|
|
1443
|
+
|
|
1444
|
+
Tested against the imported class object, not against ``__name__`` — a future
|
|
1445
|
+
``ProjectViewSet`` in another app cannot inherit the exemption by naming
|
|
1446
|
+
collision. ``isinstance`` rather than ``type(view) is``, deliberately: a subclass
|
|
1447
|
+
of this viewset still serves ``Project`` rows, which is the only thing the
|
|
1448
|
+
exemption is about, and an edition-specific subclass registered against the
|
|
1449
|
+
ADR-0030 routing hook would otherwise lose the ability to unarchive.
|
|
1450
|
+
|
|
1451
|
+
Imported inside the function because ``projects.views`` imports this module — a
|
|
1452
|
+
module-level import would be a cycle. The action-name test runs first, so the
|
|
1453
|
+
import is reached only on the four lifecycle actions, and after the first call
|
|
1454
|
+
it is a ``sys.modules`` dict hit.
|
|
1455
|
+
"""
|
|
1456
|
+
if getattr(view, "action", None) not in IsProjectNotArchived._ARCHIVE_BYPASS_ACTIONS:
|
|
1457
|
+
return False
|
|
1458
|
+
from trueppm_api.apps.projects.views import ProjectViewSet
|
|
1459
|
+
|
|
1460
|
+
return isinstance(view, ProjectViewSet)
|
|
1461
|
+
|
|
1462
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1463
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1464
|
+
return True
|
|
1465
|
+
if self._bypasses_archive_check(view):
|
|
1466
|
+
return True
|
|
1467
|
+
project_pk = _project_pk_from_view(view)
|
|
1468
|
+
if project_pk is None:
|
|
1469
|
+
# Top-level routes (ProjectViewSet) defer to has_object_permission.
|
|
1470
|
+
# DRF does not call has_object_permission on list/create, so a list
|
|
1471
|
+
# request never reaches the archived check — that's correct (listing
|
|
1472
|
+
# archived projects is read-only) and a create has no project yet.
|
|
1473
|
+
return True
|
|
1474
|
+
return not _is_project_archived(request, project_pk)
|
|
1475
|
+
|
|
1476
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1477
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1478
|
+
return True
|
|
1479
|
+
if self._bypasses_archive_check(view):
|
|
1480
|
+
return True
|
|
1481
|
+
from trueppm_api.apps.projects.models import Project
|
|
1482
|
+
|
|
1483
|
+
project_id = _get_project_id_from_obj(obj)
|
|
1484
|
+
if project_id is None:
|
|
1485
|
+
return True
|
|
1486
|
+
# Direct Project object: read the in-memory flag rather than re-querying.
|
|
1487
|
+
if isinstance(obj, Project):
|
|
1488
|
+
return not obj.is_archived
|
|
1489
|
+
return not _is_project_archived(request, project_id)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def assert_project_not_archived(project: Any) -> None:
|
|
1493
|
+
"""Refuse a write to an archived project from a service, with no request in hand.
|
|
1494
|
+
|
|
1495
|
+
**Why this exists as well as :class:`IsProjectNotArchived` (#3354).** The archived
|
|
1496
|
+
flag is *lifecycle state, not authority* — a property of the plan rather than of the
|
|
1497
|
+
caller — so unlike a role check it does not belong exclusively to the view layer.
|
|
1498
|
+
``IsProjectNotArchived`` can only run where DRF runs it: on a request, against a
|
|
1499
|
+
project it can resolve from a URL kwarg or the fetched object. Every batch/undo
|
|
1500
|
+
service in this family is reachable from a Celery task, a management command or a
|
|
1501
|
+
future endpoint that resolves its project from the request *body*, none of which the
|
|
1502
|
+
permission class sees. #3354 was exactly that shape: two undo viewsets shipped
|
|
1503
|
+
without the class and nothing underneath them noticed, so an Admin could hard-delete
|
|
1504
|
+
rows in an archived project through the undo route.
|
|
1505
|
+
|
|
1506
|
+
Raises ``PermissionDenied`` rather than returning a bool so a caller cannot ignore
|
|
1507
|
+
it, and reuses ``IsProjectNotArchived.message`` verbatim so the API contract is
|
|
1508
|
+
identical whichever layer refuses.
|
|
1509
|
+
|
|
1510
|
+
**Fail-closed on anything it cannot resolve.** A missing or unparseable id raises
|
|
1511
|
+
rather than passing. That matters because ``Project.objects.filter(pk=None)``
|
|
1512
|
+
compiles to ``id IS NULL``, which matches nothing and would read as "not archived"
|
|
1513
|
+
— a silent fail-open in a function whose entire value is being unbypassable — and
|
|
1514
|
+
because an unparseable id reaches ``UUIDField.to_python``, which raises Django's
|
|
1515
|
+
``ValidationError``: not something DRF converts, so it surfaces as a 500 instead of
|
|
1516
|
+
a refusal (the #2785 class).
|
|
1517
|
+
|
|
1518
|
+
**It always re-reads the flag, and deliberately takes an id rather than a
|
|
1519
|
+
``Project``.** The in-memory short-circuit
|
|
1520
|
+
``IsProjectNotArchived.has_object_permission`` makes just above is safe there
|
|
1521
|
+
because DRF fetched that row within the same request; a service can be handed an
|
|
1522
|
+
instance of any age, and ``enqueue_template_apply`` takes one by signature, so it
|
|
1523
|
+
could not opt out even if its caller wanted to. Reading a stale ``is_archived``
|
|
1524
|
+
off a long-lived instance is a fail-open in the one function that must not have
|
|
1525
|
+
one. This costs one indexed single-row probe per call and was measured against
|
|
1526
|
+
that trade deliberately — do not "optimize" it back into an instance fast path.
|
|
1527
|
+
"""
|
|
1528
|
+
import uuid as _uuid
|
|
1529
|
+
|
|
1530
|
+
from trueppm_api.apps.projects.models import Project
|
|
1531
|
+
|
|
1532
|
+
project_id = project.pk if isinstance(project, Project) else project
|
|
1533
|
+
if project_id is None:
|
|
1534
|
+
raise PermissionDenied(IsProjectNotArchived.message)
|
|
1535
|
+
if not isinstance(project_id, _uuid.UUID):
|
|
1536
|
+
try:
|
|
1537
|
+
project_id = _uuid.UUID(str(project_id))
|
|
1538
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
1539
|
+
raise PermissionDenied(IsProjectNotArchived.message) from exc
|
|
1540
|
+
if Project.objects.filter(pk=project_id, is_archived=True).exists():
|
|
1541
|
+
raise PermissionDenied(IsProjectNotArchived.message)
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
class IsProgramNotClosed(BasePermission):
|
|
1545
|
+
"""Block writes to programs flagged ``is_closed=True`` (#530).
|
|
1546
|
+
|
|
1547
|
+
Closed programs are read-only at the program shell (memberships, settings,
|
|
1548
|
+
ceremonies). Child projects are intentionally not gated by this check —
|
|
1549
|
+
they retain their own lifecycle and continue to accept writes.
|
|
1550
|
+
|
|
1551
|
+
The ``POST /programs/<pk>/reopen/`` action bypasses the check; ``destroy``
|
|
1552
|
+
also bypasses (an Owner can delete a closed program directly).
|
|
1553
|
+
"""
|
|
1554
|
+
|
|
1555
|
+
message = "This program is closed and cannot be modified. Reopen it first."
|
|
1556
|
+
|
|
1557
|
+
_CLOSE_BYPASS_ACTIONS: frozenset[str] = frozenset(
|
|
1558
|
+
{"reopen", "destroy", "close", "remove_sample"}
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1562
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1563
|
+
return True
|
|
1564
|
+
if getattr(view, "action", None) in self._CLOSE_BYPASS_ACTIONS:
|
|
1565
|
+
return True
|
|
1566
|
+
program_pk = _program_pk_from_view(view)
|
|
1567
|
+
if program_pk is None:
|
|
1568
|
+
return True
|
|
1569
|
+
return not _is_program_closed(request, program_pk)
|
|
1570
|
+
|
|
1571
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1572
|
+
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1573
|
+
return True
|
|
1574
|
+
if getattr(view, "action", None) in self._CLOSE_BYPASS_ACTIONS:
|
|
1575
|
+
return True
|
|
1576
|
+
from trueppm_api.apps.projects.models import Program
|
|
1577
|
+
|
|
1578
|
+
program_id = _get_program_id_from_obj(obj)
|
|
1579
|
+
if program_id is None:
|
|
1580
|
+
return True
|
|
1581
|
+
if isinstance(obj, Program):
|
|
1582
|
+
return not obj.is_closed
|
|
1583
|
+
return not _is_program_closed(request, program_id)
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def has_org_role_from_live_project(user: Any, floor: int) -> bool:
|
|
1587
|
+
"""Return True when ``user`` holds ``floor`` or above on a *live* project (#3569).
|
|
1588
|
+
|
|
1589
|
+
The single derivation behind :class:`IsOrgScheduler` and :class:`IsOrgAdmin`, and
|
|
1590
|
+
its only two callers. Superusers bypass.
|
|
1591
|
+
|
|
1592
|
+
``apps.resources`` used to carry two hand-copied mirrors of this query — one in
|
|
1593
|
+
``ResourceSerializer`` gating email exposure, one in ``views`` gating email search
|
|
1594
|
+
and the deactivated pool. They are *not* callers of this helper: #3569 moved both
|
|
1595
|
+
to :func:`~trueppm_api.apps.workspace.permissions.is_workspace_admin` (the stored
|
|
1596
|
+
``WorkspaceRole.ADMIN``), so there is nothing left to keep in sync with this
|
|
1597
|
+
function. Do not add a third copy of the query; if a new surface needs the org
|
|
1598
|
+
derivation, call this.
|
|
1599
|
+
|
|
1600
|
+
**Both soft-deleted and archived projects are excluded, and that is the point.**
|
|
1601
|
+
The historical filter was ``ProjectMembership.objects.filter(user=…,
|
|
1602
|
+
role__gte=…, is_deleted=False)`` — the ``is_deleted`` there is the *membership's*
|
|
1603
|
+
flag, so the project's own ``is_deleted`` / ``is_archived`` were never consulted.
|
|
1604
|
+
A project that had been deleted or archived years ago still conferred live
|
|
1605
|
+
org-wide authority on everyone who had once been its PM, and nothing anywhere
|
|
1606
|
+
revoked it. Archived is the stronger case of the two: the project is declared
|
|
1607
|
+
hard read-only, so continuing to read *authority* out of it contradicts the
|
|
1608
|
+
declaration that made it read-only.
|
|
1609
|
+
|
|
1610
|
+
This narrows the derivation; it does not make it trustworthy. Project roles are
|
|
1611
|
+
self-grantable by design (creating a project makes you its Owner, and that must
|
|
1612
|
+
not change), so no filter here turns membership into an org principal. Surfaces
|
|
1613
|
+
that are irreversible or exfiltrating use a **stored** principal instead: routine
|
|
1614
|
+
install-wide work (the resource catalog's deactivation lifecycle, its email
|
|
1615
|
+
exposure, the cross-project assignments view) uses
|
|
1616
|
+
:class:`~trueppm_api.apps.workspace.permissions.IsWorkspaceAdminStrict`; set-once
|
|
1617
|
+
infrastructure (mail transport) uses :class:`IsWorkspaceOperator` (ADR-0213 C1).
|
|
1618
|
+
This gate is for the shared catalogs whose blast radius is a curation mistake
|
|
1619
|
+
rather than a disclosure.
|
|
1620
|
+
"""
|
|
1621
|
+
if user is None or not getattr(user, "is_authenticated", False):
|
|
1622
|
+
return False
|
|
1623
|
+
if user.is_superuser:
|
|
1624
|
+
return True
|
|
1625
|
+
return ProjectMembership.objects.filter(
|
|
1626
|
+
user=user,
|
|
1627
|
+
role__gte=floor,
|
|
1628
|
+
is_deleted=False,
|
|
1629
|
+
project__is_deleted=False,
|
|
1630
|
+
project__is_archived=False,
|
|
1631
|
+
).exists()
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
class IsOrgScheduler(BasePermission):
|
|
1635
|
+
"""Org-level scheduler gate for the global skill catalog (#254).
|
|
1636
|
+
|
|
1637
|
+
Skill and ResourceSkill catalogs are org-shared, not project-scoped. Their
|
|
1638
|
+
write intent is "SCHEDULER+ on at least one *live* project" — equivalent to
|
|
1639
|
+
IsOrgAdmin's pattern but at the SCHEDULER floor instead of ADMIN. Memberships
|
|
1640
|
+
on archived or soft-deleted projects do not count (#3569).
|
|
1641
|
+
|
|
1642
|
+
Django superusers bypass the membership check.
|
|
1643
|
+
"""
|
|
1644
|
+
|
|
1645
|
+
message = (
|
|
1646
|
+
"You need at least Resource Manager role on at least one active project "
|
|
1647
|
+
"to manage the skill catalog."
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1651
|
+
return has_org_role_from_live_project(request.user, Role.SCHEDULER)
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
class IsOrgAdmin(BasePermission):
|
|
1655
|
+
"""Org-level admin gate for the global resource catalog (issue #155).
|
|
1656
|
+
|
|
1657
|
+
OSS has no separate org-admin entity. Admin authority is derived from
|
|
1658
|
+
project membership: any user with Project Manager (ADMIN, 3) or Owner
|
|
1659
|
+
(4) role on at least one *live* project may manage the resource catalog.
|
|
1660
|
+
Memberships on archived or soft-deleted projects do not count (#3569).
|
|
1661
|
+
|
|
1662
|
+
Django superusers bypass the membership check.
|
|
1663
|
+
|
|
1664
|
+
Enterprise installs satisfy this check implicitly — their admins always
|
|
1665
|
+
have at least one project with ADMIN role.
|
|
1666
|
+
|
|
1667
|
+
**This gate is not an org principal and must not be used as one (#3569).**
|
|
1668
|
+
Creating a project makes the creator its Owner, and nothing gates project
|
|
1669
|
+
creation — so any authenticated account can reach ADMIN on a project of its
|
|
1670
|
+
own in one request. That is correct behavior for a project role and fatal for
|
|
1671
|
+
an org-wide one. Surfaces whose blast radius is the whole install and whose
|
|
1672
|
+
effect is irreversible or exfiltrating therefore use a **stored** principal,
|
|
1673
|
+
not this gate. Which stored principal depends on the surface: routine
|
|
1674
|
+
install-wide work (the resource catalog's deactivation lifecycle, its email
|
|
1675
|
+
exposure, the cross-project assignments view) uses
|
|
1676
|
+
:class:`~trueppm_api.apps.workspace.permissions.IsWorkspaceAdminStrict`, the
|
|
1677
|
+
workspace ADMIN role, which an owner can grant in-app; set-once infrastructure
|
|
1678
|
+
(mail transport) uses :class:`IsWorkspaceOperator`, the install superuser
|
|
1679
|
+
(ADR-0213 C1). What is left on *this* gate is shared-catalog curation, where the
|
|
1680
|
+
worst outcome is a bad edit another admin can revert.
|
|
1681
|
+
|
|
1682
|
+
Note: this used to claim that enterprise overrides (LDAP group claims, SAML
|
|
1683
|
+
attributes) are "injected via signals/middleware before this check runs".
|
|
1684
|
+
No such seam exists (#2609). The membership-derived check above is the whole
|
|
1685
|
+
rule today. A documented-but-absent override point is worse than none, because
|
|
1686
|
+
an integrator builds against it and finds out at runtime.
|
|
1687
|
+
"""
|
|
1688
|
+
|
|
1689
|
+
message = (
|
|
1690
|
+
"You need Project Manager role on at least one active project "
|
|
1691
|
+
"to manage the resource catalog."
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1695
|
+
return has_org_role_from_live_project(request.user, Role.ADMIN)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def is_workspace_operator(user: Any) -> bool:
|
|
1699
|
+
"""Return True when ``user`` is the install operator — a Django superuser (#3569).
|
|
1700
|
+
|
|
1701
|
+
The single definition behind :class:`IsWorkspaceOperator`, and as of #3569 its
|
|
1702
|
+
only caller. It gates the install-global *infrastructure* config — mail
|
|
1703
|
+
transport (ADR-0213 C1) and the notification transport views.
|
|
1704
|
+
|
|
1705
|
+
An earlier revision of this branch also routed the resource catalog's email
|
|
1706
|
+
exposure and deactivated pool through here. Those moved to
|
|
1707
|
+
:func:`~trueppm_api.apps.workspace.permissions.is_workspace_admin` when #3569 was
|
|
1708
|
+
re-gated onto the **stored** ``WorkspaceRole.ADMIN``: the defect being fixed is
|
|
1709
|
+
that org authority was *self-grantable*, not that it was insufficiently powerful,
|
|
1710
|
+
and a stored role answers that while remaining grantable in-app. Superuser stays
|
|
1711
|
+
the right floor for set-once infrastructure, which is what is left here.
|
|
1712
|
+
"""
|
|
1713
|
+
return bool(user is not None and getattr(user, "is_authenticated", False) and user.is_superuser)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
class IsWorkspaceOperator(BasePermission):
|
|
1717
|
+
"""Install-operator gate for workspace-global infrastructure config (#712).
|
|
1718
|
+
|
|
1719
|
+
Stricter than :class:`IsOrgAdmin`. Some workspace settings — the outbound
|
|
1720
|
+
mail transport being the first — govern the *entire installation*, not one
|
|
1721
|
+
project. ``IsOrgAdmin`` grants write access off a single project's ADMIN
|
|
1722
|
+
role, which would let a low-trust project admin repoint every outbound
|
|
1723
|
+
message (including reset/invite mail) at an attacker relay (ADR-0213 C1).
|
|
1724
|
+
Mail-transport writes therefore require the install operator: a Django
|
|
1725
|
+
superuser. In OSS there is no separate org-operator entity, so superuser is
|
|
1726
|
+
the correct and only such principal; Enterprise may widen this via a
|
|
1727
|
+
registered override without changing the OSS baseline.
|
|
1728
|
+
|
|
1729
|
+
**Scope: set-once infrastructure only. This gate does NOT cover the resource
|
|
1730
|
+
catalog.** An intermediate revision of #3569 routed the catalog's deactivation
|
|
1731
|
+
lifecycle, ``email`` exposure and the cross-project assignments view here; they
|
|
1732
|
+
were re-gated onto
|
|
1733
|
+
:class:`~trueppm_api.apps.workspace.permissions.IsWorkspaceAdminStrict` before
|
|
1734
|
+
merge. The live callers of this class are the notification transport views.
|
|
1735
|
+
|
|
1736
|
+
The reason that split is the right one, and the rule for choosing next time: the
|
|
1737
|
+
defect #3569 fixed was that org authority came from a *self-grantable* project
|
|
1738
|
+
role, **not** that it was insufficiently powerful. Any stored principal answers
|
|
1739
|
+
that. So pick by how often the work happens, not by how bad it would be —
|
|
1740
|
+
deactivating a departed employee is routine and belongs on a role an owner can
|
|
1741
|
+
grant in-app (workspace ADMIN); repointing outbound mail is set-once and belongs
|
|
1742
|
+
on the install operator, because there is no in-app grant path for a superuser
|
|
1743
|
+
and there should not need to be one.
|
|
1744
|
+
|
|
1745
|
+
**One claim above is stale; it is kept because it is load-bearing for why this
|
|
1746
|
+
class exists, and corrected here rather than silently rewritten.** "In OSS there
|
|
1747
|
+
is no separate org-operator entity" was true for #712 and is false now:
|
|
1748
|
+
``workspace.models.WorkspaceRole`` / ``WorkspaceMembership`` is a **stored**
|
|
1749
|
+
workspace tier (MEMBER/ADMIN/OWNER), granted only by an existing workspace admin
|
|
1750
|
+
or by SSO provisioning, and therefore not self-grantable. Do not cite this
|
|
1751
|
+
docstring as evidence that no stored workspace principal exists — that reading is
|
|
1752
|
+
what sent #3569's first pass to superuser.
|
|
1753
|
+
A second stale claim: "Enterprise may widen this via a registered override"
|
|
1754
|
+
describes no seam that exists — there is no registration hook for this class
|
|
1755
|
+
anywhere in the tree. It is the same false-seam class :class:`IsOrgAdmin` records
|
|
1756
|
+
for itself under #2609. Treat the superuser test as the whole story until such a
|
|
1757
|
+
seam is actually built.
|
|
1758
|
+
"""
|
|
1759
|
+
|
|
1760
|
+
message = "Only a workspace operator (superuser) may change this setting."
|
|
1761
|
+
|
|
1762
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1763
|
+
return is_workspace_operator(request.user)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
class CanAssignResource(BasePermission):
|
|
1767
|
+
"""Allow Resource Manager (2) or above to assign resources to tasks.
|
|
1768
|
+
|
|
1769
|
+
Stub — used by a future ResourceAssignment viewset (issue #14).
|
|
1770
|
+
"""
|
|
1771
|
+
|
|
1772
|
+
message = "You need at least Resource Manager role to assign resources."
|
|
1773
|
+
|
|
1774
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1775
|
+
if not (request.user and request.user.is_authenticated):
|
|
1776
|
+
return False
|
|
1777
|
+
# Nested/object routes expose ``project_pk``: enforce the SCHEDULER floor
|
|
1778
|
+
# declaratively (mirrors IsProjectScheduler) so the gate is visible to
|
|
1779
|
+
# DRF-level audits and OpenAPI security generation. List-level creates
|
|
1780
|
+
# carry the project in the request body, which is not resolvable here —
|
|
1781
|
+
# ProjectResourceViewSet.perform_create enforces the same floor on that
|
|
1782
|
+
# path, and has_object_permission below covers detail mutations.
|
|
1783
|
+
project_pk, scope = _resolve_project_scope(view)
|
|
1784
|
+
if project_pk is not None and request.method not in ("GET", "HEAD", "OPTIONS"):
|
|
1785
|
+
role = _membership_role(request, project_pk)
|
|
1786
|
+
return role is not None and role >= Role.SCHEDULER
|
|
1787
|
+
return _unresolved_scope_allows(request, view, scope)
|
|
1788
|
+
|
|
1789
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
1790
|
+
project_id = _get_project_id_from_obj(obj)
|
|
1791
|
+
if project_id is None:
|
|
1792
|
+
return False
|
|
1793
|
+
role = _membership_role(request, project_id)
|
|
1794
|
+
return role is not None and role >= Role.SCHEDULER
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
class IsTokenForProject(BasePermission):
|
|
1798
|
+
"""Verify that request.auth (an ApiToken) is authorized for the URL project.
|
|
1799
|
+
|
|
1800
|
+
A project-scoped token (``token.project_id`` set) authorizes writes only to
|
|
1801
|
+
its bound project. A program-scoped token (``token.program_id`` set) authorizes
|
|
1802
|
+
writes to any project within that program — the URL project is checked
|
|
1803
|
+
against the program's ``projects.filter(pk=...).exists()`` membership.
|
|
1804
|
+
|
|
1805
|
+
Raises AuthenticationFailed (401, not PermissionDenied/403) on mismatch
|
|
1806
|
+
so callers cannot enumerate whether the URL project exists — a project_id
|
|
1807
|
+
not covered by the token is indistinguishable from a project_id that does
|
|
1808
|
+
not exist at all.
|
|
1809
|
+
|
|
1810
|
+
Returns True unconditionally when request.auth is not an ApiToken
|
|
1811
|
+
(i.e. JWT/Session requests) so the class is safely composable without
|
|
1812
|
+
side-effects on non-token views.
|
|
1813
|
+
|
|
1814
|
+
Used on: TaskSyncView (token-authenticated inbound sync endpoint).
|
|
1815
|
+
"""
|
|
1816
|
+
|
|
1817
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1818
|
+
import uuid
|
|
1819
|
+
|
|
1820
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
1821
|
+
|
|
1822
|
+
from trueppm_api.apps.projects.models import ApiToken
|
|
1823
|
+
|
|
1824
|
+
token = request.auth
|
|
1825
|
+
if not isinstance(token, ApiToken):
|
|
1826
|
+
return True # Non-token auth path; other classes handle it.
|
|
1827
|
+
|
|
1828
|
+
pk = view.kwargs.get("pk") or view.kwargs.get("project_pk")
|
|
1829
|
+
try:
|
|
1830
|
+
url_project_id = uuid.UUID(str(pk))
|
|
1831
|
+
except (TypeError, ValueError, AttributeError):
|
|
1832
|
+
raise AuthenticationFailed("Invalid project id.") from None
|
|
1833
|
+
|
|
1834
|
+
# Project-scoped: direct FK match.
|
|
1835
|
+
if token.project_id is not None:
|
|
1836
|
+
if token.project_id != url_project_id:
|
|
1837
|
+
raise AuthenticationFailed("Token does not belong to this project.")
|
|
1838
|
+
return True
|
|
1839
|
+
|
|
1840
|
+
# Program-scoped: project must be a member of the token's program.
|
|
1841
|
+
# The membership check runs against the live Project.program FK
|
|
1842
|
+
# (and not the soft-deleted projects) so a program-scoped token never
|
|
1843
|
+
# authorizes writes into a project that has been removed from the
|
|
1844
|
+
# program after the token was minted.
|
|
1845
|
+
if token.program_id is not None:
|
|
1846
|
+
from trueppm_api.apps.projects.models import Project
|
|
1847
|
+
|
|
1848
|
+
if not Project.objects.filter(
|
|
1849
|
+
pk=url_project_id,
|
|
1850
|
+
program_id=token.program_id,
|
|
1851
|
+
is_deleted=False,
|
|
1852
|
+
).exists():
|
|
1853
|
+
raise AuthenticationFailed("Token does not authorize this project.")
|
|
1854
|
+
return True
|
|
1855
|
+
|
|
1856
|
+
# Defense in depth: the DB CheckConstraint guarantees at least one of
|
|
1857
|
+
# project_id / program_id is non-null. If we get here, the row is
|
|
1858
|
+
# corrupt; reject the token rather than fail open.
|
|
1859
|
+
raise AuthenticationFailed("Token has no scope.")
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
# ---------------------------------------------------------------------------
|
|
1863
|
+
# API-token scopes (ADR-0186 §E — read-only MCP slice, issue #601)
|
|
1864
|
+
# ---------------------------------------------------------------------------
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def TokenHasScope(required_scope: str) -> type[BasePermission]:
|
|
1868
|
+
"""Build a permission requiring an API token to carry ``required_scope``.
|
|
1869
|
+
|
|
1870
|
+
A composable factory so the same rule works in a static ``permission_classes``
|
|
1871
|
+
list (``TokenHasScope("mcp:read")``) and inside ``get_permissions()``.
|
|
1872
|
+
|
|
1873
|
+
Semantics:
|
|
1874
|
+
* If ``request.auth`` is not one of our API tokens (human JWT/Session
|
|
1875
|
+
request), the permission PASSES — scope enforcement only constrains
|
|
1876
|
+
token-authenticated callers; RBAC classes still gate the human path.
|
|
1877
|
+
* ``legacy:full`` is a superset: a token carrying it satisfies any read
|
|
1878
|
+
scope, preserving pre-scopes behavior for every backfilled token.
|
|
1879
|
+
* Otherwise the token must list ``required_scope`` explicitly.
|
|
1880
|
+
"""
|
|
1881
|
+
|
|
1882
|
+
class _TokenHasScope(BasePermission):
|
|
1883
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1884
|
+
from trueppm_api.apps.projects.models import SCOPE_LEGACY_FULL, ApiToken
|
|
1885
|
+
|
|
1886
|
+
token = getattr(request, "auth", None)
|
|
1887
|
+
if not isinstance(token, ApiToken):
|
|
1888
|
+
return True # Non-token auth path; RBAC classes handle it.
|
|
1889
|
+
|
|
1890
|
+
token_scopes = token.scopes or []
|
|
1891
|
+
if required_scope in token_scopes:
|
|
1892
|
+
return True
|
|
1893
|
+
# legacy:full is the historical unrestricted superset — it satisfies
|
|
1894
|
+
# any read scope, but never substitutes for itself being required.
|
|
1895
|
+
return required_scope != SCOPE_LEGACY_FULL and SCOPE_LEGACY_FULL in token_scopes
|
|
1896
|
+
|
|
1897
|
+
_TokenHasScope.__name__ = f"TokenHasScope[{required_scope}]"
|
|
1898
|
+
_TokenHasScope.__qualname__ = _TokenHasScope.__name__
|
|
1899
|
+
return _TokenHasScope
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
class TokenReadOnlyMethods(BasePermission):
|
|
1903
|
+
"""Restrict *agent* API-token callers to safe (read-only) HTTP methods.
|
|
1904
|
+
|
|
1905
|
+
Additively mixing token auth onto a ModelViewSet would otherwise expose its
|
|
1906
|
+
write actions to an agent token. This class closes that hole: an ``mcp:read``
|
|
1907
|
+
token may only issue GET/HEAD/OPTIONS on the views the MCP wraps, which is the
|
|
1908
|
+
published product promise for that scope and must not be weakened.
|
|
1909
|
+
|
|
1910
|
+
Scoped to agent tokens, not to *all* tokens (#2877). A ``legacy:full`` personal
|
|
1911
|
+
access token is the owner's own credential on the general API surface (#2547),
|
|
1912
|
+
not an agent — refusing its writes 403'd every write to Task, Project, Risk,
|
|
1913
|
+
Sprint, Label, Program and Backlog while the docs and UI promised "reads and
|
|
1914
|
+
writes everything your account can." It passes here and is then governed by
|
|
1915
|
+
the view's ordinary RBAC classes against ``request.user = token.owner``, so it
|
|
1916
|
+
can never exceed its owner's own session. Human JWT/Session callers pass
|
|
1917
|
+
trivially. See :func:`~trueppm_api.apps.projects.models.is_agent_token` for why
|
|
1918
|
+
the predicate is "lacks ``legacy:full``" rather than "carries ``mcp:read``".
|
|
1919
|
+
"""
|
|
1920
|
+
|
|
1921
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
1922
|
+
from trueppm_api.apps.agents.models import RefusalConstraint
|
|
1923
|
+
from trueppm_api.apps.projects.models import is_agent_token
|
|
1924
|
+
|
|
1925
|
+
if not is_agent_token(getattr(request, "auth", None)):
|
|
1926
|
+
return True
|
|
1927
|
+
if request.method in SAFE_METHODS:
|
|
1928
|
+
return True
|
|
1929
|
+
# "I minted a read-only token and pointed my write script at it" is the most
|
|
1930
|
+
# likely first-hour integration mistake, and a bare 403 is the least
|
|
1931
|
+
# diagnosable answer to it (#2878). capability_scope is disclosable: it
|
|
1932
|
+
# describes the caller's own credential, not any resource.
|
|
1933
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
1934
|
+
return False
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
class IsNotTokenAuthenticated(BasePermission):
|
|
1938
|
+
"""Refuse API-token callers on the credential-management surface (#2878).
|
|
1939
|
+
|
|
1940
|
+
A leaked personal access token must not be able to *extend itself*. Without this
|
|
1941
|
+
guard a bearer-only caller reached ``/me/api-tokens/`` through the default
|
|
1942
|
+
authentication stack and could ``POST`` fresh siblings up to the 10-token cap,
|
|
1943
|
+
``GET`` the owner's whole token inventory, and ``DELETE`` the owner's legitimate
|
|
1944
|
+
tokens to break their automations. Revoking the leaked token was therefore not
|
|
1945
|
+
containment — the attacker already held credentials the owner had never seen, and
|
|
1946
|
+
``features/personal-access-tokens.md#revoking-a-token`` promises the opposite.
|
|
1947
|
+
GitHub blocks PAT-manages-PAT for the same reason.
|
|
1948
|
+
|
|
1949
|
+
Applied to the whole surface rather than only its writes: the ``GET`` is the
|
|
1950
|
+
reconnaissance step (it names every sibling token, its scopes and its expiry),
|
|
1951
|
+
and there is no automation story for "my script enumerates my own credentials"
|
|
1952
|
+
that justifies leaving it open. Managing credentials requires being present —
|
|
1953
|
+
a session or a JWT.
|
|
1954
|
+
|
|
1955
|
+
**What this covers, precisely.** API tokens (personal, project, program) and their
|
|
1956
|
+
audit logs, the per-user connected-account credential store, and — since #3551 — the
|
|
1957
|
+
SSO provider admin surface (``/workspace/sso/providers/``). The SSO routes are here
|
|
1958
|
+
because they mint a durable grant *indirectly*: an admin's leaked token could widen
|
|
1959
|
+
``allowed_email_domains`` and set ``auto_create_members`` with an ADMIN
|
|
1960
|
+
``default_role``, and one SSO login at the widened domain then produces a real
|
|
1961
|
+
ADMIN session that revoking the token does not touch.
|
|
1962
|
+
|
|
1963
|
+
Since #2939, it also covers the two other routes that mint a durable grant of
|
|
1964
|
+
some kind: ``GitAutomationRotateSecretView`` (rotates a project's git-automation
|
|
1965
|
+
webhook secret) and the POST branch of ``ProjectShareLinkListCreateView`` (mints
|
|
1966
|
+
a public share link). Both grants are also now swept by off-boarding and by
|
|
1967
|
+
``revoke_api_tokens --all`` (``revoke_personal_durable_grants``), closing the
|
|
1968
|
+
gap where a leaked PAT belonging to a project Admin could mint one and outlive
|
|
1969
|
+
every revocation lever.
|
|
1970
|
+
|
|
1971
|
+
Several membership routes have the same shape and are **not** covered — workspace
|
|
1972
|
+
invites, member role change, group membership and group→project grants,
|
|
1973
|
+
``transfer-ownership`` (which hands OWNER away, and is therefore *above* the
|
|
1974
|
+
escalation #3551 closed), and the project/program membership viewsets in
|
|
1975
|
+
``apps/access/views.py``. **Nothing tracks that set.** #3551's *Related* section
|
|
1976
|
+
defers the filing to the team, so until an issue exists this paragraph is the
|
|
1977
|
+
only record of it. Do not read the list as exhaustive either; ``tests/apps/access/
|
|
1978
|
+
token_write_surface.txt`` is the inventory and this docstring is not a second copy
|
|
1979
|
+
of it. So "revoke the token and you are contained" is true for the credential,
|
|
1980
|
+
sign-in-configuration, share-link and git-automation surfaces and is not a
|
|
1981
|
+
whole-system property — say the narrower thing in operator docs.
|
|
1982
|
+
|
|
1983
|
+
**The predicate is ``request.auth``, and that is only sound because it runs as a
|
|
1984
|
+
permission.** An identity refusal is raised by the *authenticator*, so on that
|
|
1985
|
+
path ``request.auth`` is still ``None`` and this class never executes at all —
|
|
1986
|
+
DRF has already answered 401 before permissions are consulted. The 401 case is
|
|
1987
|
+
therefore covered by the authenticator, not here, and a test suite that only
|
|
1988
|
+
exercises live tokens (403) would prove nothing about it. Both paths are pinned
|
|
1989
|
+
in ``tests/apps/projects/test_token_management_is_session_only.py``.
|
|
1990
|
+
"""
|
|
1991
|
+
|
|
1992
|
+
# Phrased for the whole surface, not just the token routes (#3551). It is now also
|
|
1993
|
+
# the message an admin sees when a script tries to configure SSO, and "API tokens
|
|
1994
|
+
# cannot manage API tokens" would be a confidently wrong diagnosis there.
|
|
1995
|
+
message = (
|
|
1996
|
+
"API tokens cannot manage credentials or sign-in configuration. "
|
|
1997
|
+
"Sign in to perform this action."
|
|
1998
|
+
)
|
|
1999
|
+
|
|
2000
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
2001
|
+
from trueppm_api.apps.agents.models import RefusalConstraint
|
|
2002
|
+
from trueppm_api.apps.projects.models import ApiToken
|
|
2003
|
+
|
|
2004
|
+
if not isinstance(getattr(request, "auth", None), ApiToken):
|
|
2005
|
+
return True
|
|
2006
|
+
# Scope-blind on purpose, unlike the MCP guards (#2877): this is not an agent
|
|
2007
|
+
# control. *No* token of any scope may manage credentials, so asking about the
|
|
2008
|
+
# scope would only create a way to get the answer wrong.
|
|
2009
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2010
|
+
return False
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
class McpInstanceEnabled(BasePermission):
|
|
2014
|
+
"""Instance-wide MCP kill switch (#2021, ADR-0497).
|
|
2015
|
+
|
|
2016
|
+
When an operator sets ``TRUEPPM_MCP_ENABLED = False`` (env
|
|
2017
|
+
``TRUEPPM_MCP_ENABLED``), every MCP-token read is denied at this single
|
|
2018
|
+
chokepoint — even though the token exists and carries ``mcp:read``. It is the
|
|
2019
|
+
"no agent access on this instance, period" lever a self-hosting operator
|
|
2020
|
+
(Persona 10) reaches for, mirroring the public-board-sharing kill switch
|
|
2021
|
+
(ADR-0245) but enforced at the mixin's one guard chokepoint rather than per
|
|
2022
|
+
view.
|
|
2023
|
+
|
|
2024
|
+
Agent-scoped and fail-closed. A non-token (human JWT/Session) request PASSES
|
|
2025
|
+
unconditionally, so the switch never affects normal user auth on the same
|
|
2026
|
+
viewset; an agent token is DENIED (``False`` → 403) whenever the switch
|
|
2027
|
+
is off. Placed ahead of the scope checks so a disabled instance short-circuits
|
|
2028
|
+
them (only the credential-identity guard ``TokenIsOwnerScoped`` precedes it — see
|
|
2029
|
+
``mcp_token_guards``). A denied read is still recorded as a ``POLICY`` refusal
|
|
2030
|
+
by the mixin's existing agent-action audit.
|
|
2031
|
+
|
|
2032
|
+
A ``legacy:full`` personal access token also passes unconditionally (#2877).
|
|
2033
|
+
This switch is the operator's "no *agent* access on this instance" lever, and
|
|
2034
|
+
``administration/mcp-server.md`` promises it affects "only agent (MCP token)
|
|
2035
|
+
traffic. People are never affected." A ``legacy:full`` PAT is a person's own CI
|
|
2036
|
+
script; blanking its reads made that sentence false and did so *silently* — a
|
|
2037
|
+
filtered collection returns ``count: 0``, indistinguishable from "no rows", so
|
|
2038
|
+
a nightly export wrote an empty file on a 200.
|
|
2039
|
+
"""
|
|
2040
|
+
|
|
2041
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
2042
|
+
from django.conf import settings
|
|
2043
|
+
|
|
2044
|
+
from trueppm_api.apps.agents.models import RefusalConstraint
|
|
2045
|
+
from trueppm_api.apps.projects.models import is_agent_token
|
|
2046
|
+
|
|
2047
|
+
if not is_agent_token(getattr(request, "auth", None)):
|
|
2048
|
+
return True # Human JWT/Session or the owner's own full-access PAT.
|
|
2049
|
+
# why: single operator chokepoint. When MCP is disabled instance-wide the
|
|
2050
|
+
# token still exists (and may carry mcp:read), but it must not grant agent
|
|
2051
|
+
# access. Denying here — before the scope/owner guards — fails closed for
|
|
2052
|
+
# the agent path only, leaving human auth on the same viewset untouched.
|
|
2053
|
+
if not bool(getattr(settings, "TRUEPPM_MCP_ENABLED", True)):
|
|
2054
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2055
|
+
return False
|
|
2056
|
+
return True
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
class TokenIsOwnerScoped(BasePermission):
|
|
2060
|
+
"""Confine the MCP read surface to owner-scoped (personal) API tokens (#1712).
|
|
2061
|
+
|
|
2062
|
+
Confused-deputy / blast-radius guard. A project- or program-scoped token is
|
|
2063
|
+
confined to its bound scope on the *write* path by ``IsTokenForProject`` (the
|
|
2064
|
+
URL project pk is checked against the token's project/program). That check has
|
|
2065
|
+
no analogue on the MCP *read* surface: the collection tools (``list_projects``,
|
|
2066
|
+
``list_programs``, ``list_tasks``, ``/me/work/``) carry no project pk, so there
|
|
2067
|
+
is nothing to check the token against. Because a project/program token
|
|
2068
|
+
authenticates *as its human minter*, those tools would then return every
|
|
2069
|
+
project or program the minter can see — not just the one the token is bound to.
|
|
2070
|
+
A token minted to read a single project becomes a credential that reads the
|
|
2071
|
+
minter's entire membership: exactly the over-broad, hard-to-reason-about blast
|
|
2072
|
+
radius a scoped token is meant to prevent.
|
|
2073
|
+
|
|
2074
|
+
The simplest correct policy (per #1712) is to accept ONLY owner-scoped
|
|
2075
|
+
(personal) tokens here and reject project/program tokens with a 401. A personal
|
|
2076
|
+
token *is* its owner, so DRF's own object-level RBAC already confines its reads
|
|
2077
|
+
to exactly what that user may see — there is no over-return to defend against.
|
|
2078
|
+
Project/program tokens keep their designed write/sync surface unchanged.
|
|
2079
|
+
|
|
2080
|
+
**This guard deliberately stays keyed on the token's *type*, not its scope**,
|
|
2081
|
+
while the four guards around it became scope-aware in #2877. It is load-bearing
|
|
2082
|
+
that it did not follow them: project and program tokens carry ``legacy:full`` by
|
|
2083
|
+
*default* (``_default_api_token_scopes``), so a scope-aware version of this check
|
|
2084
|
+
would pass every one of them and re-open the exact #1712 confused-deputy hole —
|
|
2085
|
+
a token minted to read one project becoming a credential that reads its minter's
|
|
2086
|
+
entire membership, now with writes attached. The question this guard asks ("is
|
|
2087
|
+
this credential the human it acts as?") is orthogonal to the question
|
|
2088
|
+
:func:`~trueppm_api.apps.projects.models.is_agent_token` asks ("is this
|
|
2089
|
+
credential an agent?"), and conflating them fails open.
|
|
2090
|
+
|
|
2091
|
+
Rejects with ``AuthenticationFailed`` (401, not 403) to match the rest of the
|
|
2092
|
+
token surface — a caller cannot distinguish "wrong token type" from "no such
|
|
2093
|
+
resource", preventing enumeration. Non-token callers (human JWT/Session) pass
|
|
2094
|
+
unconditionally; their access is governed by the view's RBAC classes.
|
|
2095
|
+
"""
|
|
2096
|
+
|
|
2097
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
2098
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
2099
|
+
|
|
2100
|
+
from trueppm_api.apps.agents.models import RefusalConstraint
|
|
2101
|
+
from trueppm_api.apps.projects.models import ApiToken
|
|
2102
|
+
|
|
2103
|
+
token = getattr(request, "auth", None)
|
|
2104
|
+
if not isinstance(token, ApiToken):
|
|
2105
|
+
return True # Non-token auth path; RBAC classes handle it.
|
|
2106
|
+
if token.owner_id is not None:
|
|
2107
|
+
return True
|
|
2108
|
+
# A project/program token on the read surface is refused for *what the
|
|
2109
|
+
# token is*, not for what it asked — token_identity, not capability_scope
|
|
2110
|
+
# (#2689). Both are disclosable: they describe the credential the caller
|
|
2111
|
+
# already holds.
|
|
2112
|
+
_mark_identity_refusal(request, RefusalConstraint.TOKEN_IDENTITY)
|
|
2113
|
+
raise AuthenticationFailed("Token is not authorized for the MCP read surface.")
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
class McpScope(StrEnum):
|
|
2117
|
+
"""How an :class:`McpReadableViewMixin` subclass is scoped to a project (ADR-0678).
|
|
2118
|
+
|
|
2119
|
+
Every subclass MUST declare one. The declaration is not documentation — it
|
|
2120
|
+
selects which enforcement mechanism carries the team-level MCP opt-out (#2482)
|
|
2121
|
+
for that view, and a subclass that declares nothing is **denied** to token
|
|
2122
|
+
callers by :class:`McpProjectEnabled`. A future MCP-readable view that forgets
|
|
2123
|
+
therefore fails *closed* rather than silently joining the agent-readable
|
|
2124
|
+
surface unfiltered.
|
|
2125
|
+
"""
|
|
2126
|
+
|
|
2127
|
+
PATH = "path"
|
|
2128
|
+
"""Project is resolvable from the URL (``project_pk``/``project_id``, or a
|
|
2129
|
+
``Project`` detail pk). :class:`McpProjectEnabled` denies 403 directly."""
|
|
2130
|
+
|
|
2131
|
+
QUERYSET = "queryset"
|
|
2132
|
+
"""Rows carry a project FK; the mixin's ``get_queryset`` excludes rows whose
|
|
2133
|
+
project has opted out. Detail routes resolve through the same filtered
|
|
2134
|
+
queryset, so an opted-out object 404s rather than leaking."""
|
|
2135
|
+
|
|
2136
|
+
AGGREGATE = "aggregate"
|
|
2137
|
+
"""The view spans projects and assembles its response by hand. The mixin still
|
|
2138
|
+
filters any queryset it has, but the view MUST additionally intersect its own
|
|
2139
|
+
reads with :func:`~trueppm_api.apps.projects.mcp_settings.mcp_visible_project_ids`.
|
|
2140
|
+
Weaker than the other three — the conformance test can only assert the helper
|
|
2141
|
+
is called, not that it is called correctly, so these views need direct tests."""
|
|
2142
|
+
|
|
2143
|
+
NO_PROJECT_DATA = "none"
|
|
2144
|
+
"""The response carries no project-scoped data at all (identity echo). Only
|
|
2145
|
+
``MeView`` qualifies; adding a member here is a security decision."""
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
class McpProjectEnabled(BasePermission):
|
|
2149
|
+
"""Team-level MCP opt-out enforcement point (ADR-0678, #2482).
|
|
2150
|
+
|
|
2151
|
+
The consent counterpart to :class:`McpInstanceEnabled`: where that is the
|
|
2152
|
+
*operator's* instance-wide lever, this is the *team's* lever over reads of its
|
|
2153
|
+
own data — the answer to *"consent that only an admin can grant or revoke on
|
|
2154
|
+
the team's behalf is consent in name only"* (#2415).
|
|
2155
|
+
|
|
2156
|
+
Agent-scoped and fail-closed, like every other guard here: a non-token (human
|
|
2157
|
+
JWT/Session) request — and a ``legacy:full`` personal access token, which is a
|
|
2158
|
+
person's own credential rather than an agent (#2877) — passes unconditionally,
|
|
2159
|
+
so nothing about normal user auth on the shared viewsets changes. For an agent
|
|
2160
|
+
token it denies when:
|
|
2161
|
+
|
|
2162
|
+
* a scope above every project denies (workspace switch off — the instance
|
|
2163
|
+
switch is already short-circuited by :class:`McpInstanceEnabled` first), or
|
|
2164
|
+
* the view declares no :class:`McpScope` (declare-or-deny), or
|
|
2165
|
+
* the view is :attr:`McpScope.PATH` and its URL-resolved project has opted
|
|
2166
|
+
out — including the case where the project cannot be resolved at all,
|
|
2167
|
+
which is treated as a denial rather than a pass.
|
|
2168
|
+
|
|
2169
|
+
``QUERYSET`` / ``AGGREGATE`` / ``NO_PROJECT_DATA`` views pass here; their
|
|
2170
|
+
enforcement is row-level (see ``McpReadableViewMixin.get_queryset``) or
|
|
2171
|
+
explicit in the view. Because all guards are ANDed, this composes with the
|
|
2172
|
+
instance switch such that neither can override the other in the permissive
|
|
2173
|
+
direction — by construction, with no precedence logic to get wrong.
|
|
2174
|
+
"""
|
|
2175
|
+
|
|
2176
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
2177
|
+
from trueppm_api.apps.agents.models import RefusalConstraint
|
|
2178
|
+
from trueppm_api.apps.projects.mcp_settings import (
|
|
2179
|
+
mcp_reads_globally_disabled,
|
|
2180
|
+
resolve_mcp_enabled,
|
|
2181
|
+
)
|
|
2182
|
+
from trueppm_api.apps.projects.models import Project, is_agent_token
|
|
2183
|
+
|
|
2184
|
+
if not is_agent_token(getattr(request, "auth", None)):
|
|
2185
|
+
return True # Human JWT/Session or the owner's own full-access PAT.
|
|
2186
|
+
|
|
2187
|
+
if mcp_reads_globally_disabled():
|
|
2188
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2189
|
+
return False
|
|
2190
|
+
|
|
2191
|
+
scope = getattr(view, "mcp_scope", None)
|
|
2192
|
+
if scope is None:
|
|
2193
|
+
# Declare-or-deny: an MCP-readable view that never declared how it is
|
|
2194
|
+
# project-scoped is denied outright. A forgotten view fails closed.
|
|
2195
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2196
|
+
return False
|
|
2197
|
+
if scope != McpScope.PATH:
|
|
2198
|
+
# Row-level (QUERYSET) or view-explicit (AGGREGATE) enforcement; a
|
|
2199
|
+
# NO_PROJECT_DATA view exposes nothing to scope.
|
|
2200
|
+
return True
|
|
2201
|
+
|
|
2202
|
+
view_kwargs = getattr(view, "kwargs", {}) or {}
|
|
2203
|
+
# Resolution order: the two nested-router conventions, then the view's
|
|
2204
|
+
# declared kwarg (default ``pk`` — the project-scoped APIViews are all
|
|
2205
|
+
# routed as ``projects/<pk>/...``). A PATH view whose ``pk`` is NOT a
|
|
2206
|
+
# project must override ``mcp_project_kwarg``; the per-view 403 tests are
|
|
2207
|
+
# what prove each one resolves correctly.
|
|
2208
|
+
project_kwarg = getattr(view, "mcp_project_kwarg", "pk")
|
|
2209
|
+
project_id = (
|
|
2210
|
+
view_kwargs.get("project_pk")
|
|
2211
|
+
or view_kwargs.get("project_id")
|
|
2212
|
+
or view_kwargs.get(project_kwarg)
|
|
2213
|
+
)
|
|
2214
|
+
if project_id is None:
|
|
2215
|
+
# A PATH view whose project could not be resolved is a declaration bug,
|
|
2216
|
+
# not a public read. Fail closed rather than admit an unscoped token.
|
|
2217
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2218
|
+
return False
|
|
2219
|
+
|
|
2220
|
+
project = Project.objects.filter(pk=project_id).only("mcp_enabled", "program_id").first()
|
|
2221
|
+
if project is None:
|
|
2222
|
+
# Nonexistent/soft-deleted project — let the view's own 404 path answer;
|
|
2223
|
+
# there is no project data to protect.
|
|
2224
|
+
return True
|
|
2225
|
+
if not resolve_mcp_enabled(project):
|
|
2226
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2227
|
+
return False
|
|
2228
|
+
return True
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
class McpProgramExportConsent(BasePermission):
|
|
2232
|
+
"""Refuse an agent a program bulk export when a member project opted out (#3014).
|
|
2233
|
+
|
|
2234
|
+
The gap this closes: ``ProgramViewSet`` declares :attr:`McpScope.AGGREGATE`, so
|
|
2235
|
+
:class:`McpProjectEnabled` passes unconditionally, and the mixin's ``Program``
|
|
2236
|
+
branch in ``_mcp_filter_queryset`` governs only ``program.mcp_enabled``. Both
|
|
2237
|
+
program bulk exports — the synchronous JSON seed and the async ``.tar.gz``
|
|
2238
|
+
bundle — carry **every member project's rows verbatim**, so an ``mcp:read`` token
|
|
2239
|
+
could read through the parent exactly the data a child team had explicitly closed
|
|
2240
|
+
to agents. Read via the child's own endpoints, those rows are withheld; the
|
|
2241
|
+
export was the way around that.
|
|
2242
|
+
|
|
2243
|
+
Applied to the two export reads only, and to **token callers only** — a human
|
|
2244
|
+
Admin's export is untouched under either policy, because ADR-0678 governs agents,
|
|
2245
|
+
not people. It is composed into ``ProgramViewSet._rbac_permissions()`` rather than
|
|
2246
|
+
into ``mcp_token_guards()``: the guards there apply to every action on the
|
|
2247
|
+
viewset, and this question is meaningful for exactly two of them.
|
|
2248
|
+
|
|
2249
|
+
The behavior is chosen by ``TRUEPPM_MCP_PROGRAM_EXPORT_POLICY``, an operator
|
|
2250
|
+
setting — see :func:`~trueppm_api.apps.projects.mcp_settings.program_export_policy`
|
|
2251
|
+
for why it is deliberately not a workspace or program field.
|
|
2252
|
+
|
|
2253
|
+
Marks the refusal as ``policy``/``capability_scope``, which reaches the caller
|
|
2254
|
+
through the ADR-0809 refusal envelope — an agent that cannot tell *why* it was
|
|
2255
|
+
refused makes the same call again.
|
|
2256
|
+
|
|
2257
|
+
It does **not** currently reach the agent-action audit log, and that is a
|
|
2258
|
+
pre-existing defect of the substrate rather than of this guard: under
|
|
2259
|
+
``ATOMIC_REQUESTS`` DRF's ``exception_handler`` calls ``set_rollback()`` for every
|
|
2260
|
+
``APIException``, so the row ``finalize_response`` writes on a refusal path is
|
|
2261
|
+
discarded. **No** refusal from any guard is recorded today — measured, see #3017.
|
|
2262
|
+
Do not read ``finalize_response``'s docstring claim that it "commits" as fact.
|
|
2263
|
+
"""
|
|
2264
|
+
|
|
2265
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
2266
|
+
from trueppm_api.apps.agents.models import RefusalConstraint
|
|
2267
|
+
from trueppm_api.apps.projects.mcp_settings import program_export_withheld_from_agents
|
|
2268
|
+
from trueppm_api.apps.projects.models import Program, is_agent_token
|
|
2269
|
+
|
|
2270
|
+
if not is_agent_token(getattr(request, "auth", None)):
|
|
2271
|
+
return True # Human JWT/Session, or the owner's own full-access PAT.
|
|
2272
|
+
|
|
2273
|
+
# The program is the detail pk on every route this guards. An unresolvable
|
|
2274
|
+
# id is left to the view's own 404 — there is no export to protect, and
|
|
2275
|
+
# answering 403 here would turn a nonexistent program into a distinguishable
|
|
2276
|
+
# one.
|
|
2277
|
+
view_kwargs = getattr(view, "kwargs", {}) or {}
|
|
2278
|
+
program_id = view_kwargs.get("pk")
|
|
2279
|
+
if program_id is None:
|
|
2280
|
+
return True
|
|
2281
|
+
program = Program.objects.filter(pk=program_id).only("pk").first()
|
|
2282
|
+
if program is None:
|
|
2283
|
+
return True
|
|
2284
|
+
|
|
2285
|
+
if program_export_withheld_from_agents(program):
|
|
2286
|
+
_mark_policy_refusal(request, RefusalConstraint.CAPABILITY_SCOPE)
|
|
2287
|
+
return False
|
|
2288
|
+
return True
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
if TYPE_CHECKING:
|
|
2292
|
+
_McpViewBase = APIView
|
|
2293
|
+
else:
|
|
2294
|
+
_McpViewBase = object
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
class McpReadableViewMixin(_McpViewBase):
|
|
2298
|
+
"""Additively expose a read view to ``mcp:read`` API tokens (ADR-0186 §E).
|
|
2299
|
+
|
|
2300
|
+
Mixed in *before* the concrete view class so ``super()`` resolves to the real
|
|
2301
|
+
``APIView``/``ViewSet``. It leaves the existing authentication and RBAC
|
|
2302
|
+
permission classes intact and only *adds*:
|
|
2303
|
+
|
|
2304
|
+
* ``ProjectApiTokenAuthentication`` (prepended, so a ``tppm_`` bearer is
|
|
2305
|
+
recognized before JWT — which the auth class defers to for non-``tppm_``
|
|
2306
|
+
bearers), and
|
|
2307
|
+
* ``TokenReadOnlyMethods`` + ``TokenHasScope("mcp:read")`` (appended, so a
|
|
2308
|
+
token caller is confined to safe methods and must carry the read scope;
|
|
2309
|
+
human callers pass both trivially).
|
|
2310
|
+
|
|
2311
|
+
The base type is ``APIView`` only under ``TYPE_CHECKING`` (``object`` at
|
|
2312
|
+
runtime) so mypy resolves ``super().get_authenticators()`` /
|
|
2313
|
+
``get_permissions()`` without the mixin claiming to be a standalone view.
|
|
2314
|
+
"""
|
|
2315
|
+
|
|
2316
|
+
mcp_scope: ClassVar[McpScope | None] = None
|
|
2317
|
+
"""How this view is scoped to a project for the team MCP opt-out (ADR-0678).
|
|
2318
|
+
|
|
2319
|
+
**Required on every subclass.** ``None`` means "undeclared", and
|
|
2320
|
+
:class:`McpProjectEnabled` denies token reads on an undeclared view — so
|
|
2321
|
+
forgetting this fails closed rather than exposing the view unfiltered. See
|
|
2322
|
+
:class:`McpScope` for which value to pick.
|
|
2323
|
+
"""
|
|
2324
|
+
|
|
2325
|
+
mcp_project_kwarg: ClassVar[str] = "pk"
|
|
2326
|
+
"""URL kwarg holding the project id, for :attr:`McpScope.PATH` views.
|
|
2327
|
+
|
|
2328
|
+
``project_pk`` and ``project_id`` are always tried first (the nested-router
|
|
2329
|
+
conventions). The default ``pk`` covers the project-scoped ``APIView``s, which
|
|
2330
|
+
are all routed as ``projects/<pk>/...``. Override on a PATH view whose ``pk``
|
|
2331
|
+
is some other entity — otherwise its opt-out check would read the wrong id.
|
|
2332
|
+
"""
|
|
2333
|
+
|
|
2334
|
+
mcp_compute_heavy: bool = False
|
|
2335
|
+
"""Set ``True`` on a subclass whose read triggers a CPM/Monte Carlo recompute.
|
|
2336
|
+
|
|
2337
|
+
Adds the tighter :class:`~trueppm_api.apps.access.throttles.McpTokenComputeThrottle`
|
|
2338
|
+
bucket on top of the baseline per-token read throttle for the four compute-heavy
|
|
2339
|
+
tools — ``whatif``, ``monte-carlo/latest``, ``forecast``, ``sprint-forecast``
|
|
2340
|
+
(#1808 finding F4). Leave ``False`` for the cheap metadata reads.
|
|
2341
|
+
"""
|
|
2342
|
+
|
|
2343
|
+
def get_authenticators(self) -> list[BaseAuthentication]:
|
|
2344
|
+
from trueppm_api.apps.projects.authentication import (
|
|
2345
|
+
ProjectApiTokenAuthentication,
|
|
2346
|
+
)
|
|
2347
|
+
|
|
2348
|
+
return [ProjectApiTokenAuthentication(), *super().get_authenticators()]
|
|
2349
|
+
|
|
2350
|
+
def get_throttles(self) -> list[BaseThrottle]:
|
|
2351
|
+
"""Add per-token MCP throttles without disturbing the view's own throttles.
|
|
2352
|
+
|
|
2353
|
+
Token-authenticated reads on the MCP surface were unbounded (#1808 F4). The
|
|
2354
|
+
baseline :class:`McpTokenReadThrottle` bounds every MCP-readable view per
|
|
2355
|
+
token; compute-heavy views additionally stack
|
|
2356
|
+
:class:`McpTokenComputeThrottle`. Both are no-ops for human JWT/Session
|
|
2357
|
+
callers (their ``get_cache_key`` returns ``None``), so a view's existing
|
|
2358
|
+
throttles and the default ``user`` throttle keep governing human traffic.
|
|
2359
|
+
"""
|
|
2360
|
+
from trueppm_api.apps.access.throttles import (
|
|
2361
|
+
McpTokenComputeThrottle,
|
|
2362
|
+
McpTokenReadThrottle,
|
|
2363
|
+
)
|
|
2364
|
+
|
|
2365
|
+
throttles = list(super().get_throttles())
|
|
2366
|
+
throttles.append(McpTokenReadThrottle())
|
|
2367
|
+
if self.mcp_compute_heavy:
|
|
2368
|
+
throttles.append(McpTokenComputeThrottle())
|
|
2369
|
+
return throttles
|
|
2370
|
+
|
|
2371
|
+
def mcp_token_guards(self) -> list[BasePermission]:
|
|
2372
|
+
"""MCP agent-token guards to append to a view's RBAC permission list.
|
|
2373
|
+
|
|
2374
|
+
All five pass unconditionally for human JWT/Session auth, so they are safe
|
|
2375
|
+
to append to *every* action's list. For an **agent** token they confine it
|
|
2376
|
+
to: the instance MCP switch being on (``McpInstanceEnabled``, placed first
|
|
2377
|
+
so a disabled instance short-circuits everything else), the team's consent
|
|
2378
|
+
(``McpProjectEnabled``), safe methods (``TokenReadOnlyMethods``), and the
|
|
2379
|
+
``mcp:read`` scope (``TokenHasScope``). ViewSets that override
|
|
2380
|
+
``get_permissions`` with per-action lists call this from their wrapper so no
|
|
2381
|
+
branch — including write branches — can leak a token past the guards.
|
|
2382
|
+
|
|
2383
|
+
Two of the five have a wider reach than "agent", on purpose:
|
|
2384
|
+
|
|
2385
|
+
* ``TokenHasScope("mcp:read")`` is satisfied by ``legacy:full`` as a read
|
|
2386
|
+
superset, so a full-access PAT is not stopped by it;
|
|
2387
|
+
* ``TokenIsOwnerScoped`` applies to **every** ``ApiToken`` regardless of
|
|
2388
|
+
scope, and must — it closes the confused-deputy hole (#1712) that
|
|
2389
|
+
project/program tokens (which default to ``legacy:full``) would otherwise
|
|
2390
|
+
walk straight through.
|
|
2391
|
+
|
|
2392
|
+
``TokenIsOwnerScoped`` is **first** since #2877. Before, it was
|
|
2393
|
+
defense-in-depth *behind* ``TokenReadOnlyMethods``, which refused every token
|
|
2394
|
+
an unsafe method; now that ``TokenReadOnlyMethods`` passes ``legacy:full``, it
|
|
2395
|
+
is the sole barrier between a project/program token and a write here. Ordering
|
|
2396
|
+
it first makes that structural: the question "is this credential the human it
|
|
2397
|
+
acts as?" is answered before any capability question, the way
|
|
2398
|
+
``McpInstanceEnabled`` used to be first for the operator's question. Outcomes
|
|
2399
|
+
are unchanged — DRF ANDs the list — but a project/program token now gets its
|
|
2400
|
+
401 from the guard that is actually deciding, rather than a 403 from whichever
|
|
2401
|
+
capability check happened to be consulted first.
|
|
2402
|
+
"""
|
|
2403
|
+
from trueppm_api.apps.projects.models import SCOPE_MCP_READ
|
|
2404
|
+
|
|
2405
|
+
return [
|
|
2406
|
+
TokenIsOwnerScoped(),
|
|
2407
|
+
McpInstanceEnabled(),
|
|
2408
|
+
McpProjectEnabled(),
|
|
2409
|
+
TokenReadOnlyMethods(),
|
|
2410
|
+
TokenHasScope(SCOPE_MCP_READ)(),
|
|
2411
|
+
]
|
|
2412
|
+
|
|
2413
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
2414
|
+
# DRF instantiates each permission_class, so these are BasePermission
|
|
2415
|
+
# instances at runtime; the stub types them via a Protocol, hence the cast.
|
|
2416
|
+
existing = cast("list[BasePermission]", list(super().get_permissions()))
|
|
2417
|
+
return [*existing, *self.mcp_token_guards()]
|
|
2418
|
+
|
|
2419
|
+
def filter_queryset(self, queryset: QuerySet[Any]) -> QuerySet[Any]:
|
|
2420
|
+
"""Primary collection-level enforcement point for the MCP opt-out (ADR-0678).
|
|
2421
|
+
|
|
2422
|
+
why here and not only in ``get_queryset``: **three of the eight
|
|
2423
|
+
queryset-backed MCP viewsets build their queryset from scratch rather than
|
|
2424
|
+
calling ``super().get_queryset()``** (``ProgramViewSet``,
|
|
2425
|
+
``BacklogItemViewSet``, ``MeWorkView``). For those, a ``get_queryset``
|
|
2426
|
+
override on this mixin is never reached — the filter would silently fail
|
|
2427
|
+
*open* on exactly the collections that need it most. DRF calls
|
|
2428
|
+
``filter_queryset()`` from both ``ListModelMixin.list()`` and
|
|
2429
|
+
``GenericAPIView.get_object()`` regardless of how the queryset was built, and
|
|
2430
|
+
no MCP-readable view overrides it, so this is the one hook every list and
|
|
2431
|
+
detail read passes through.
|
|
2432
|
+
|
|
2433
|
+
``get_queryset`` below *also* filters, for the five viewsets that do chain to
|
|
2434
|
+
super and for actions that read ``self.get_queryset()`` directly without
|
|
2435
|
+
going through ``filter_queryset`` (e.g. ``ProjectViewSet.health_summary``).
|
|
2436
|
+
Double-filtering is harmless — the narrowing is idempotent.
|
|
2437
|
+
"""
|
|
2438
|
+
qs = cast("QuerySet[Any]", super().filter_queryset(queryset)) # type: ignore[misc]
|
|
2439
|
+
if self.mcp_scope in (None, McpScope.NO_PROJECT_DATA):
|
|
2440
|
+
return qs
|
|
2441
|
+
return self._mcp_filter_queryset(qs)
|
|
2442
|
+
|
|
2443
|
+
def get_queryset(self) -> QuerySet[Any]:
|
|
2444
|
+
"""Exclude rows whose project has opted out of agent reads (ADR-0678, #2482).
|
|
2445
|
+
|
|
2446
|
+
This is the collection-level half of the enforcement point. The guard
|
|
2447
|
+
(:class:`McpProjectEnabled`) can only see a project that appears in the URL,
|
|
2448
|
+
which is ``None`` for every list endpoint — so a guard-only opt-out would be
|
|
2449
|
+
bypassed by any collection carrying the project as a query param
|
|
2450
|
+
(``/tasks/?project=X``), the same confused-deputy shape #1712 closed. Row
|
|
2451
|
+
filtering here closes it for all eleven queryset-backed MCP views at once.
|
|
2452
|
+
|
|
2453
|
+
Applies to token callers only, so human JWT/Session reads on the same
|
|
2454
|
+
viewset are untouched. Runs *after* ``super().get_queryset()`` — which for a
|
|
2455
|
+
``ProjectScopedViewSet`` is the membership filter — so MCP consent narrows
|
|
2456
|
+
an already-membership-scoped queryset and can never widen it. Detail routes
|
|
2457
|
+
resolve through this same queryset, so an opted-out object 404s.
|
|
2458
|
+
"""
|
|
2459
|
+
qs = cast("QuerySet[Any]", super().get_queryset()) # type: ignore[misc]
|
|
2460
|
+
if self.mcp_scope in (None, McpScope.NO_PROJECT_DATA):
|
|
2461
|
+
return qs
|
|
2462
|
+
return self._mcp_filter_queryset(qs)
|
|
2463
|
+
|
|
2464
|
+
def _mcp_filter_queryset(self, qs: QuerySet[Any]) -> QuerySet[Any]:
|
|
2465
|
+
"""Narrow ``qs`` to projects readable by this agent token, or return it as-is.
|
|
2466
|
+
|
|
2467
|
+
Resolves the project relation the same way ``ProjectScopedViewSet`` does
|
|
2468
|
+
(``project`` FK → ``predecessor__project`` → the ``Project`` row itself), so
|
|
2469
|
+
the two stay consistent and a model with an unusual shape is handled in one
|
|
2470
|
+
place. A model with no reachable project relation is returned unfiltered —
|
|
2471
|
+
it holds no project-scoped rows to withhold.
|
|
2472
|
+
"""
|
|
2473
|
+
from trueppm_api.apps.projects.models import is_agent_token
|
|
2474
|
+
|
|
2475
|
+
request = getattr(self, "request", None)
|
|
2476
|
+
if request is None:
|
|
2477
|
+
return qs
|
|
2478
|
+
|
|
2479
|
+
model = qs.model
|
|
2480
|
+
|
|
2481
|
+
if model.__name__ == "Program":
|
|
2482
|
+
# A program is withheld only when the program itself denied; its
|
|
2483
|
+
# projects are filtered on their own endpoints. Reading the program row
|
|
2484
|
+
# is not reading a project's data.
|
|
2485
|
+
#
|
|
2486
|
+
# Handled before ``mcp_visible_project_ids`` is even consulted (#3022):
|
|
2487
|
+
# that helper fast-paths on whether any *project* has opted out, so a
|
|
2488
|
+
# denying program with zero member projects contributed no rows to that
|
|
2489
|
+
# set and this branch was never reached — the program's own denial went
|
|
2490
|
+
# unenforced. `qs.exclude(mcp_enabled=False)` reads only the program's
|
|
2491
|
+
# own column and needs no project-id set at all, so it does not depend
|
|
2492
|
+
# on that fast path — only on whether this is an agent token at all,
|
|
2493
|
+
# the same gate every other branch below applies via `visible is None`.
|
|
2494
|
+
#
|
|
2495
|
+
# The reasoning does not hold for the two program BULK EXPORTS
|
|
2496
|
+
# (``ProgramViewSet.export`` and ``export_job_download``), which carry
|
|
2497
|
+
# every member project's rows verbatim in one artifact this branch
|
|
2498
|
+
# cannot narrow. Those are governed separately by
|
|
2499
|
+
# ``McpProgramExportConsent`` (#3014); this branch is not their ruling.
|
|
2500
|
+
if not is_agent_token(getattr(request, "auth", None)):
|
|
2501
|
+
return qs
|
|
2502
|
+
filtered = qs.exclude(mcp_enabled=False)
|
|
2503
|
+
request._mcp_scope_filtered = True
|
|
2504
|
+
return filtered
|
|
2505
|
+
|
|
2506
|
+
from trueppm_api.apps.projects.mcp_settings import mcp_visible_project_ids
|
|
2507
|
+
|
|
2508
|
+
visible = mcp_visible_project_ids(request)
|
|
2509
|
+
if visible is None:
|
|
2510
|
+
# Not a token caller, or no project on the instance has opted out.
|
|
2511
|
+
return qs
|
|
2512
|
+
|
|
2513
|
+
field_names = {f.name for f in model._meta.get_fields()}
|
|
2514
|
+
if "project" in field_names:
|
|
2515
|
+
filtered = qs.filter(project_id__in=visible)
|
|
2516
|
+
elif "predecessor" in field_names:
|
|
2517
|
+
filtered = qs.filter(predecessor__project_id__in=visible)
|
|
2518
|
+
elif model.__name__ == "Project":
|
|
2519
|
+
filtered = qs.filter(pk__in=visible)
|
|
2520
|
+
elif "program" in field_names:
|
|
2521
|
+
# Program-owned rows with no project FK (the program backlog pool).
|
|
2522
|
+
# Governed by the program's own denial — a child project's opt-out does
|
|
2523
|
+
# not withhold program-level intake data it does not own.
|
|
2524
|
+
filtered = qs.exclude(program__mcp_enabled=False)
|
|
2525
|
+
else:
|
|
2526
|
+
return qs
|
|
2527
|
+
|
|
2528
|
+
# Record that something was withheld so the audit row is not an unqualified
|
|
2529
|
+
# "allowed" (ADR-0678 T8). Set as a flag, not a count: counting would cost a
|
|
2530
|
+
# second aggregate query on every filtered read.
|
|
2531
|
+
request._mcp_scope_filtered = True
|
|
2532
|
+
return filtered
|
|
2533
|
+
|
|
2534
|
+
def finalize_response(self, request: Request, response: Any, *args: Any, **kwargs: Any) -> Any:
|
|
2535
|
+
"""Record the per-action agent audit for a token-authenticated MCP read (#1805).
|
|
2536
|
+
|
|
2537
|
+
``finalize_response`` runs exactly once per request for **both** a successful
|
|
2538
|
+
read and a DRF-handled refusal (auth/permission exceptions are turned into
|
|
2539
|
+
responses, so this still runs). It is therefore the single point where an
|
|
2540
|
+
``allowed`` read and a ``policy`` refusal are both audited exactly once
|
|
2541
|
+
(ADR-0112 RC1).
|
|
2542
|
+
|
|
2543
|
+
The two halves reach the database by different routes, and must (#3017,
|
|
2544
|
+
ADR-0902):
|
|
2545
|
+
|
|
2546
|
+
* an **allowed** read is written inline and fail-closed — if
|
|
2547
|
+
``record_agent_action`` cannot persist, the exception propagates and the
|
|
2548
|
+
request rolls back, because an audit substrate must never serve an un-audited
|
|
2549
|
+
read;
|
|
2550
|
+
* a **refusal** is *queued*, not written. This method runs inside the
|
|
2551
|
+
ATOMIC_REQUESTS transaction, and DRF's ``exception_handler`` has already
|
|
2552
|
+
called ``set_rollback()`` for the exception that produced the 4xx — so an
|
|
2553
|
+
INSERT issued here would execute and then be discarded. It is drained by
|
|
2554
|
+
``AgentActionAuditMiddleware`` once that transaction has closed.
|
|
2555
|
+
|
|
2556
|
+
Until #3017 this docstring claimed the refusal path "commits". It did not, and
|
|
2557
|
+
the log held zero refusals of any kind as a result.
|
|
2558
|
+
"""
|
|
2559
|
+
|
|
2560
|
+
response = super().finalize_response(request, response, *args, **kwargs)
|
|
2561
|
+
self._record_mcp_agent_action(request, response)
|
|
2562
|
+
return response
|
|
2563
|
+
|
|
2564
|
+
def _record_mcp_agent_action(self, request: Request, response: Any) -> None:
|
|
2565
|
+
from trueppm_api.apps.projects.models import ProjectApiToken, is_agent_token
|
|
2566
|
+
|
|
2567
|
+
# Only an *agent*-token call is an agent action. A human JWT/session read on
|
|
2568
|
+
# the same view is not audited. ``successful_authenticator is None`` covers
|
|
2569
|
+
# both "auth failed" and "anonymous"; guarding on it also means we never touch
|
|
2570
|
+
# ``request.auth`` in a way that could re-trigger (and re-raise) a failed
|
|
2571
|
+
# authentication inside finalize_response. (Identity refusals — a revoked/expired
|
|
2572
|
+
# token — are audited in the authenticator, which still has the token context.)
|
|
2573
|
+
#
|
|
2574
|
+
# ``successful_authenticator`` is a *lazy* DRF property: if authentication was
|
|
2575
|
+
# never attempted this request, reading it runs ``_authenticate()`` for the
|
|
2576
|
+
# first time right here. That happens when an exception is raised in
|
|
2577
|
+
# ``initial()`` *before* ``perform_authentication()`` — e.g. an unsupported URL
|
|
2578
|
+
# format suffix (``/projects/0.5/`` parses as pk="0", format="5") fails content
|
|
2579
|
+
# negotiation first. DRF's exception_handler has already called
|
|
2580
|
+
# ``set_rollback()`` for that exception by the time we get here, so triggering
|
|
2581
|
+
# authentication's own DB lookup (``JWTAuthentication.get_user()``) now raises
|
|
2582
|
+
# ``TransactionManagementError`` instead of returning cleanly (#2989). Check
|
|
2583
|
+
# ``_authenticator`` first — DRF sets it (to an authenticator or ``None``)
|
|
2584
|
+
# on every *attempted* authentication, so its absence means one never ran, and
|
|
2585
|
+
# a request that was never authenticated is not an authenticated agent-token
|
|
2586
|
+
# call either way.
|
|
2587
|
+
if not hasattr(request, "_authenticator"):
|
|
2588
|
+
return
|
|
2589
|
+
if getattr(request, "successful_authenticator", None) is None:
|
|
2590
|
+
return
|
|
2591
|
+
token = getattr(request, "auth", None)
|
|
2592
|
+
if not isinstance(token, ProjectApiToken):
|
|
2593
|
+
return
|
|
2594
|
+
|
|
2595
|
+
status_code = getattr(response, "status_code", 200)
|
|
2596
|
+
# A non-agent token's *successful* read is not agent activity and is not recorded
|
|
2597
|
+
# (#2877). The row would say otherwise on three fields at once —
|
|
2598
|
+
# ``actor_kind=MCP_TOKEN``, ``capability_used=mcp:read``, and a summary reading
|
|
2599
|
+
# "MCP POST …" — so logging a person's CI script there is the same inverted trail
|
|
2600
|
+
# #2878 filed against the revocation log. It would also half-close #2749
|
|
2601
|
+
# (governing ``legacy:full`` token writes, 0.5) on eight viewsets while leaving
|
|
2602
|
+
# the ~260 other token-writable routes in
|
|
2603
|
+
# ``tests/apps/access/token_write_surface.txt`` unrecorded, and a partial ledger
|
|
2604
|
+
# is worse than a documented gap because it reads as complete.
|
|
2605
|
+
#
|
|
2606
|
+
# A **refusal** is not scope-filtered, and the asymmetry is deliberate even
|
|
2607
|
+
# though it is inert today. Project and program tokens carry ``legacy:full`` by
|
|
2608
|
+
# default, so a scope-only test here would exclude exactly the event most worth
|
|
2609
|
+
# keeping: a scoped integration credential walked against the collection tools
|
|
2610
|
+
# (``/me/work/``, ``/me/search``, ``/workspace/assets``) to turn a one-project
|
|
2611
|
+
# token into a read of its minter's whole membership — the #1712 confused-deputy
|
|
2612
|
+
# attempt.
|
|
2613
|
+
#
|
|
2614
|
+
# This predicate was written while the branch below it was inert: every
|
|
2615
|
+
# permission-layer refusal reached a write whose INSERT ``set_rollback()`` then
|
|
2616
|
+
# discarded, so no refusal row survived and the asymmetry could not be observed.
|
|
2617
|
+
# #3017 supplied the out-of-transaction write the earlier note anticipated (the
|
|
2618
|
+
# refusal is queued and drained by ``AgentActionAuditMiddleware``), so the
|
|
2619
|
+
# predicate is now load-bearing rather than aspirational — a scoped token walked
|
|
2620
|
+
# against the collection tools is recorded.
|
|
2621
|
+
if status_code < 400 and not is_agent_token(token):
|
|
2622
|
+
return
|
|
2623
|
+
|
|
2624
|
+
from trueppm_api.apps.agents.deferred import queue_agent_action
|
|
2625
|
+
from trueppm_api.apps.agents.models import AgentActionVerdict, AgentActorKind
|
|
2626
|
+
from trueppm_api.apps.agents.services import (
|
|
2627
|
+
hash_request_payload,
|
|
2628
|
+
record_agent_action,
|
|
2629
|
+
)
|
|
2630
|
+
from trueppm_api.apps.projects.models import SCOPE_MCP_READ
|
|
2631
|
+
|
|
2632
|
+
status = status_code
|
|
2633
|
+
allowed = status < 400
|
|
2634
|
+
if status >= 500:
|
|
2635
|
+
# A server error is not a refusal. The taxonomy has no ERROR verdict, so the
|
|
2636
|
+
# only row this code could write says refused/policy/capability_scope — i.e.
|
|
2637
|
+
# "a guard denied you", about a request no guard ever ruled on. That was
|
|
2638
|
+
# latent while every refusal row was rolled back (#3017); now that they
|
|
2639
|
+
# persist, writing it would put a false denial in the operator's log and
|
|
2640
|
+
# inflate the exact signal the log exists to carry. Recorded as nothing
|
|
2641
|
+
# until the enum grows an error member.
|
|
2642
|
+
return
|
|
2643
|
+
verdict = AgentActionVerdict.ALLOWED if allowed else AgentActionVerdict.REFUSED
|
|
2644
|
+
refusal_reason, refusal_constraint = self._mcp_refusal_classification(request, allowed)
|
|
2645
|
+
|
|
2646
|
+
action, object_type, object_id, project_id = self._mcp_audit_target(request)
|
|
2647
|
+
summary = f"MCP {request.method} {action}"
|
|
2648
|
+
if not allowed:
|
|
2649
|
+
summary += f" — refused ({status})"
|
|
2650
|
+
elif getattr(request, "_mcp_scope_filtered", False):
|
|
2651
|
+
# ADR-0678 T8: a collection read that had opted-out projects filtered out
|
|
2652
|
+
# returns 200, so it would otherwise be recorded as an unqualified
|
|
2653
|
+
# "allowed". Mark it so the Agents panel (#2481) shows a scoped read for
|
|
2654
|
+
# what it is. Verdict stays ALLOWED deliberately — adding a PARTIAL member
|
|
2655
|
+
# to AgentActionVerdict is a breaking enum change consumed by the shipped
|
|
2656
|
+
# web client (tracked as follow-up, not smuggled into 0.4).
|
|
2657
|
+
summary += " — consent-scoped (opted-out projects withheld)"
|
|
2658
|
+
|
|
2659
|
+
audit_kwargs: dict[str, Any] = {
|
|
2660
|
+
"actor_kind": AgentActorKind.MCP_TOKEN,
|
|
2661
|
+
"actor_token": token,
|
|
2662
|
+
"principal": token.owner,
|
|
2663
|
+
"action": action,
|
|
2664
|
+
"method": request.method or "",
|
|
2665
|
+
"capability_used": SCOPE_MCP_READ,
|
|
2666
|
+
"verdict": verdict,
|
|
2667
|
+
"refusal_reason": refusal_reason,
|
|
2668
|
+
"refusal_constraint": refusal_constraint,
|
|
2669
|
+
"object_type": object_type,
|
|
2670
|
+
"object_id": object_id,
|
|
2671
|
+
"project_id": project_id,
|
|
2672
|
+
"payload_hash": hash_request_payload(request),
|
|
2673
|
+
"summary": summary,
|
|
2674
|
+
"source_ip": _mcp_client_ip(request),
|
|
2675
|
+
}
|
|
2676
|
+
# Stamped now either way: the span is the *current* request's, and on the
|
|
2677
|
+
# deferred path it may well have ended by the time the queue drains.
|
|
2678
|
+
_set_agent_span_attributes(token, str(verdict))
|
|
2679
|
+
|
|
2680
|
+
if allowed:
|
|
2681
|
+
# Fail-closed, and inline for that reason: a successful read that we could
|
|
2682
|
+
# not audit must not be served, so the write shares the read's transaction —
|
|
2683
|
+
# it raises, ATOMIC_REQUESTS rolls back, and the request 500s. This is the
|
|
2684
|
+
# one case where being inside the request transaction is the point.
|
|
2685
|
+
record_agent_action(**audit_kwargs)
|
|
2686
|
+
return
|
|
2687
|
+
|
|
2688
|
+
# A refusal cannot be written here at all. DRF's exception_handler has already
|
|
2689
|
+
# called set_rollback() for the APIException that produced this 4xx, so an INSERT
|
|
2690
|
+
# issued now executes and is then discarded when the request transaction unwinds —
|
|
2691
|
+
# which is exactly why this log contained only successes (#3017). Queue it for
|
|
2692
|
+
# AgentActionAuditMiddleware, which runs after ATOMIC_REQUESTS has closed.
|
|
2693
|
+
queue_agent_action(request, **audit_kwargs)
|
|
2694
|
+
|
|
2695
|
+
def _mcp_refusal_classification(self, request: Request, allowed: bool) -> tuple[str, str]:
|
|
2696
|
+
"""Return the ``(refusal_reason, refusal_constraint)`` for an MCP audit row.
|
|
2697
|
+
|
|
2698
|
+
Both are empty for an allowed read.
|
|
2699
|
+
"""
|
|
2700
|
+
from trueppm_api.apps.agents.models import AgentActionRefusalReason, RefusalConstraint
|
|
2701
|
+
|
|
2702
|
+
if allowed:
|
|
2703
|
+
return "", ""
|
|
2704
|
+
# An authenticated token rejected by an MCP guard is a *policy* refusal (the
|
|
2705
|
+
# actor is known; a capability/scope check denied it). The finer constraint
|
|
2706
|
+
# (ADR-0421, #1850) is capability_scope — an MCP-scope denial carries no schedule
|
|
2707
|
+
# projected impact, so its side-car impact stays empty.
|
|
2708
|
+
refusal_reason: str = AgentActionRefusalReason.POLICY
|
|
2709
|
+
refusal_constraint: str = RefusalConstraint.CAPABILITY_SCOPE
|
|
2710
|
+
# Prefer what the guard that actually denied recorded (#2689). The
|
|
2711
|
+
# response body is built from the same marks, so the wire and the
|
|
2712
|
+
# audit row can never disagree about why a call was refused — which
|
|
2713
|
+
# they would if this kept assuming capability_scope while the caller
|
|
2714
|
+
# was told token_identity.
|
|
2715
|
+
from trueppm_api.apps.agents.refusal import refusal_marks
|
|
2716
|
+
|
|
2717
|
+
marks = refusal_marks(request)
|
|
2718
|
+
if marks is not None:
|
|
2719
|
+
marked_reason, marked_constraint = marks
|
|
2720
|
+
refusal_reason = marked_reason or refusal_reason
|
|
2721
|
+
refusal_constraint = marked_constraint or refusal_constraint
|
|
2722
|
+
return refusal_reason, refusal_constraint
|
|
2723
|
+
|
|
2724
|
+
def _mcp_audit_target(self, request: Request) -> tuple[str, str, str, Any | None]:
|
|
2725
|
+
"""Best-effort ``(action, object_type, object_id, project_id)`` for the audit row.
|
|
2726
|
+
|
|
2727
|
+
Total by construction — never raises, so a metadata edge case cannot 500 a read
|
|
2728
|
+
(only the DB write itself is fail-closed).
|
|
2729
|
+
"""
|
|
2730
|
+
|
|
2731
|
+
match = getattr(request, "resolver_match", None)
|
|
2732
|
+
action = ""
|
|
2733
|
+
if match is not None:
|
|
2734
|
+
action = match.view_name or match.url_name or ""
|
|
2735
|
+
action = action or type(self).__name__
|
|
2736
|
+
|
|
2737
|
+
view_kwargs = getattr(self, "kwargs", {}) or {}
|
|
2738
|
+
object_id = str(view_kwargs.get("pk") or "")
|
|
2739
|
+
project_id = view_kwargs.get("project_pk") or view_kwargs.get("project_id") or None
|
|
2740
|
+
|
|
2741
|
+
object_type = ""
|
|
2742
|
+
queryset = getattr(self, "queryset", None)
|
|
2743
|
+
model = getattr(queryset, "model", None)
|
|
2744
|
+
if model is not None:
|
|
2745
|
+
object_type = model.__name__
|
|
2746
|
+
# A retrieve on the Project view: the object *is* the project.
|
|
2747
|
+
if project_id is None and object_type == "Project" and object_id:
|
|
2748
|
+
project_id = object_id
|
|
2749
|
+
|
|
2750
|
+
return action, object_type, object_id, project_id
|
|
2751
|
+
|
|
2752
|
+
|
|
2753
|
+
def _mcp_client_ip(request: Request) -> str | None:
|
|
2754
|
+
"""Client IP for the audit row: leftmost X-Forwarded-For hop, else REMOTE_ADDR."""
|
|
2755
|
+
|
|
2756
|
+
xff = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
2757
|
+
if xff:
|
|
2758
|
+
return xff.split(",")[0].strip() or None
|
|
2759
|
+
return request.META.get("REMOTE_ADDR") or None
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def _set_agent_span_attributes(token: Any, verdict: str) -> None:
|
|
2763
|
+
"""Attach agent attributes to the current span — best-effort (never breaks a read)."""
|
|
2764
|
+
|
|
2765
|
+
try:
|
|
2766
|
+
from opentelemetry import trace
|
|
2767
|
+
|
|
2768
|
+
from trueppm_api.apps.observability.otel import attributes as attrs
|
|
2769
|
+
|
|
2770
|
+
span = trace.get_current_span()
|
|
2771
|
+
if span is None:
|
|
2772
|
+
return
|
|
2773
|
+
span.set_attribute(attrs.AGENT_TOKEN_PREFIX, token.token_prefix)
|
|
2774
|
+
span.set_attribute(attrs.AGENT_CAPABILITY, "mcp:read")
|
|
2775
|
+
span.set_attribute(attrs.AGENT_ACTOR_KIND, "mcp_token")
|
|
2776
|
+
span.set_attribute(attrs.AGENT_VERDICT, verdict)
|
|
2777
|
+
except Exception:
|
|
2778
|
+
# Telemetry is best-effort; a span/exporter hiccup must not fail an audited read.
|
|
2779
|
+
pass
|
|
2780
|
+
|
|
2781
|
+
|
|
2782
|
+
# ---------------------------------------------------------------------------
|
|
2783
|
+
# ProjectScopedViewSet mixin
|
|
2784
|
+
# ---------------------------------------------------------------------------
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
class ProjectScopedViewSet(IdempotencyMixin, viewsets.GenericViewSet): # type: ignore[type-arg]
|
|
2788
|
+
"""Mixin that restricts every queryset to projects the user is a member of.
|
|
2789
|
+
|
|
2790
|
+
Prevents IDOR: an unauthenticated or non-member request will receive an
|
|
2791
|
+
empty queryset rather than all objects in the database.
|
|
2792
|
+
|
|
2793
|
+
Only active (non-soft-deleted) memberships grant queryset access (M1 fix).
|
|
2794
|
+
|
|
2795
|
+
Subclasses should call super().get_queryset() and then apply additional
|
|
2796
|
+
filters on top of the membership-scoped queryset.
|
|
2797
|
+
|
|
2798
|
+
Inherits IdempotencyMixin (ADR-0170) so every project-scoped mutation honors the
|
|
2799
|
+
Idempotency-Key header. The mixin precedes GenericViewSet in the MRO so its
|
|
2800
|
+
initial()/finalize_response()/handle_exception() overrides run inside the
|
|
2801
|
+
ATOMIC_REQUESTS transaction. Opt out with ``idempotency_exempt = True``.
|
|
2802
|
+
"""
|
|
2803
|
+
|
|
2804
|
+
def get_queryset(self) -> QuerySet[Any]:
|
|
2805
|
+
qs = super().get_queryset()
|
|
2806
|
+
user = getattr(self.request, "user", None)
|
|
2807
|
+
if user is None or not user.is_authenticated:
|
|
2808
|
+
return qs.none()
|
|
2809
|
+
|
|
2810
|
+
member_project_ids = ProjectMembership.objects.filter(
|
|
2811
|
+
user=user,
|
|
2812
|
+
is_deleted=False, # M1: exclude soft-deleted memberships
|
|
2813
|
+
).values_list("project_id", flat=True)
|
|
2814
|
+
|
|
2815
|
+
# Determine the project FK path. Projects are their own primary key.
|
|
2816
|
+
# Tasks, Dependencies, and other models have project_id or
|
|
2817
|
+
# predecessor__project_id.
|
|
2818
|
+
model = qs.model
|
|
2819
|
+
field_names = {f.name for f in model._meta.get_fields()}
|
|
2820
|
+
|
|
2821
|
+
if "project" in field_names:
|
|
2822
|
+
return qs.filter(project_id__in=member_project_ids)
|
|
2823
|
+
if "predecessor" in field_names:
|
|
2824
|
+
# Dependency: filter through predecessor's project
|
|
2825
|
+
return qs.filter(predecessor__project_id__in=member_project_ids)
|
|
2826
|
+
# Project itself — filter by PK membership, excluding soft-deleted
|
|
2827
|
+
# projects. Without is_deleted=False a soft-deleted project still
|
|
2828
|
+
# resolves on retrieve/list/update/destroy (the membership row survives
|
|
2829
|
+
# the project's soft-delete), leaving a "zombie" project reachable at its
|
|
2830
|
+
# old URL — the same defect the explicit is_deleted=False guard prevents
|
|
2831
|
+
# on every other project lookup (#1111).
|
|
2832
|
+
if model.__name__ == "Project":
|
|
2833
|
+
return qs.filter(pk__in=member_project_ids, is_deleted=False)
|
|
2834
|
+
# Calendar and other non-project-scoped models: fall through unfiltered.
|
|
2835
|
+
# Calendars are org-level shared resources; scoping is documented as
|
|
2836
|
+
# intentional for the OSS single-tenant model (M2 decision: accept).
|
|
2837
|
+
return qs
|