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