trueppm-api 0.4.0b2__py3-none-any.whl

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