trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1473 @@
1
+ """Multi-provider SSO service layer (ADR-0517 §3.2, supersedes ADR-0187 §2).
2
+
3
+ Pure functions for the Authorization Code + PKCE (OIDC) and Authorization Code
4
+ (GitHub OAuth2) flows, kept out of the views so the security-critical logic
5
+ (state/PKCE/nonce, token exchange, ID-token validation, GitHub userinfo, identity
6
+ resolution, membership creation) is testable in isolation.
7
+
8
+ ADR-0517 adopts ``allauth.socialaccount`` as the provider **registry** — the
9
+ per-provider endpoint/claim knowledge — but **not** its network layer. Every
10
+ outbound call (OIDC discovery / token / JWKS, GitHub ``/user`` + ``/user/emails``
11
+ + org membership) still routes through the shared SSRF egress chokepoint
12
+ (``apps/integrations/http``). allauth is used only for endpoint constants and
13
+ claim shape; **it never opens a socket** (control 1). We keep our own
14
+ ``validate_id_token`` (alg allow-list + nonce, JWKS via egress) rather than
15
+ delegating to allauth's verification, which would fetch JWKS off-egress and not
16
+ enforce our alg allow-list (control 4).
17
+
18
+ Config lives in allauth's ``SocialApp`` (``provider``, ``provider_id``,
19
+ ``client_id``, ``settings.server_url``) + our :class:`SsoProviderPolicy` side row
20
+ (the Fernet-encrypted client secret + policy fields); per-user bindings live in
21
+ allauth's ``SocialAccount``. The only ephemeral state (``state`` → provider slug +
22
+ PKCE verifier + nonce + the exact redirect_uri) lives in the Django cache (Valkey
23
+ in prod) with a short TTL and is **single-use** (deleted on read).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import logging
31
+ import secrets
32
+ from dataclasses import dataclass
33
+ from typing import Any
34
+ from urllib.parse import urlencode
35
+
36
+ import jwt
37
+ from allauth.socialaccount.models import SocialApp
38
+ from django.conf import settings
39
+ from django.contrib.auth import get_user_model
40
+ from django.core.cache import cache
41
+ from django.core.mail import EmailMultiAlternatives
42
+ from django.db import transaction
43
+ from django.utils import timezone
44
+ from django.utils.html import escape
45
+ from jwt import PyJWK, PyJWKSet
46
+ from jwt.exceptions import PyJWKError, PyJWKSetError
47
+
48
+ from trueppm_api.apps.integrations import http as egress
49
+ from trueppm_api.apps.sso.extensions import oidc_role_for
50
+ from trueppm_api.apps.sso.models import SsoProviderPolicy
51
+ from trueppm_api.apps.workspace.models import MemberStatus, WorkspaceMembership, WorkspaceRole
52
+ from trueppm_api.apps.workspace.permissions import workspace_role_for_user
53
+
54
+ logger = logging.getLogger("trueppm.sso")
55
+
56
+ User = get_user_model()
57
+
58
+ # The two allauth provider modules we enable (ADR-0517 §1). Every OIDC IdP maps to
59
+ # ``openid_connect``; GitHub is the one non-OIDC OAuth2 IdP.
60
+ ALLAUTH_OPENID_CONNECT = "openid_connect"
61
+ ALLAUTH_GITHUB = "github"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RegistryEntry:
66
+ """A fixed, server-defined SSO provider type (ADR-0517 §2).
67
+
68
+ ``slug`` is the admin-facing registry id **and** the openid_connect
69
+ ``provider_id`` / SocialAccount ``provider`` key. ``allauth_provider`` selects
70
+ the flow branch (OIDC vs GitHub OAuth2). ``kind`` is display metadata for the
71
+ admin picker (the FE composes ``server_url`` for the ``derived`` types and the
72
+ server re-validates it as an absolute https URL).
73
+ """
74
+
75
+ slug: str
76
+ display: str
77
+ allauth_provider: str
78
+ kind: str # free | fixed | derived | oauth
79
+
80
+
81
+ # The fixed provider registry (ADR-0517 §2, design handoff table). Every OIDC IdP
82
+ # uses allauth's ``openid_connect`` provider as a named app keyed by our slug; only
83
+ # GitHub uses the dedicated ``github`` OAuth2 module.
84
+ REGISTRY: dict[str, RegistryEntry] = {
85
+ "generic": RegistryEntry("generic", "Generic OIDC", ALLAUTH_OPENID_CONNECT, "free"),
86
+ "google": RegistryEntry("google", "Google", ALLAUTH_OPENID_CONNECT, "fixed"),
87
+ "entra": RegistryEntry("entra", "Microsoft Entra ID", ALLAUTH_OPENID_CONNECT, "derived"),
88
+ "gitlab": RegistryEntry("gitlab", "GitLab", ALLAUTH_OPENID_CONNECT, "derived"),
89
+ "keycloak": RegistryEntry("keycloak", "Keycloak", ALLAUTH_OPENID_CONNECT, "derived"),
90
+ "authentik": RegistryEntry("authentik", "Authentik", ALLAUTH_OPENID_CONNECT, "derived"),
91
+ "zitadel": RegistryEntry("zitadel", "Zitadel", ALLAUTH_OPENID_CONNECT, "derived"),
92
+ "okta": RegistryEntry("okta", "Okta", ALLAUTH_OPENID_CONNECT, "derived"),
93
+ "auth0": RegistryEntry("auth0", "Auth0", ALLAUTH_OPENID_CONNECT, "derived"),
94
+ "github": RegistryEntry("github", "GitHub", ALLAUTH_GITHUB, "oauth"),
95
+ }
96
+
97
+
98
+ def registry_entry(slug: str) -> RegistryEntry | None:
99
+ return REGISTRY.get(slug)
100
+
101
+
102
+ # Server-fixed scopes (ADR-0517 §3.4). The admin cannot widen them; ``groups`` /
103
+ # custom claims are an enterprise widening. Mirrors SOCIALACCOUNT_PROVIDERS.
104
+ OIDC_SCOPES = ["openid", "email", "profile"]
105
+ GITHUB_SCOPES = ["read:user", "user:email"]
106
+
107
+ # GitHub OAuth2 endpoints (ADR-0517 §3.2). Kept as module constants — allauth's
108
+ # github adapter (GitHubOAuth2Adapter) is used only as a reference for these
109
+ # values and the claim shape; the network call is ours, through egress.
110
+ GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
111
+ GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
112
+ GITHUB_USER_URL = "https://api.github.com/user"
113
+ GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
114
+ # The synthetic "issuer" recorded in SocialAccount.extra_data for GitHub bindings
115
+ # (GitHub has no OIDC issuer). Preserves parity with the OIDC ``iss`` disambiguator.
116
+ GITHUB_ISSUER = "https://github.com"
117
+
118
+ # ID-token signature algorithms we accept. Asymmetric only — a symmetric ``HS*``
119
+ # token would be "verified" with the client secret, and ``none`` must never be
120
+ # honored. PyJWT rejects any ``alg`` not in this allow-list (and ``none`` outright
121
+ # when a key is supplied), closing the alg-confusion class.
122
+ _ALLOWED_ID_TOKEN_ALGS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
123
+
124
+ # Short, single-use login state. 5 minutes is ample for an interactive IdP
125
+ # round-trip (consent screen included) and bounds how long a leaked ``state`` URL
126
+ # parameter is replayable (it's also deleted on first read).
127
+ _STATE_TTL_SECONDS = getattr(settings, "OIDC_STATE_TTL_SECONDS", 300)
128
+
129
+ # Discovery documents are stable; caching avoids re-fetching the well-known doc
130
+ # on every login (perf) without holding it so long that an issuer reconfiguration
131
+ # is invisible for hours.
132
+ _DISCOVERY_TTL_SECONDS = getattr(settings, "OIDC_DISCOVERY_TTL_SECONDS", 3600)
133
+
134
+ # JWKS documents change only when the IdP rotates keys, so re-fetching one on
135
+ # every single login is pure added latency plus an avoidable outbound request per
136
+ # sign-in. Shorter than the discovery TTL on purpose: the kid-miss refetch in
137
+ # ``_signing_key_for`` already makes a *newly added* key visible immediately, so
138
+ # the only thing the TTL bounds is how long a *withdrawn* key stays trusted — and
139
+ # that window should be minutes, not an hour.
140
+ _JWKS_TTL_SECONDS = getattr(settings, "OIDC_JWKS_TTL_SECONDS", 600)
141
+
142
+ # Clock-skew allowance when validating an ID token's time claims. 60 seconds is
143
+ # the conventional OIDC/JWT allowance (RFC 7519 §4.1.4 explicitly provides for
144
+ # "some small leeway"; the mainstream IdP SDKs default to a minute). With the
145
+ # PyJWT default of 0 an IdP whose clock is even a second ahead of ours issues
146
+ # tokens we reject as not-yet-valid, which surfaces to the user as an
147
+ # undiagnosable login failure on a correctly configured provider. It is held to
148
+ # one minute rather than the several some libraries allow because the same
149
+ # allowance symmetrically extends how long an *expired* token stays acceptable,
150
+ # and an ID token is consumed once, immediately after issuance.
151
+ _ID_TOKEN_LEEWAY_SECONDS = 60
152
+
153
+ _STATE_KEY_PREFIX = "oidc:state:"
154
+ _DISCOVERY_KEY_PREFIX = "oidc:discovery:"
155
+ _JWKS_KEY_PREFIX = "oidc:jwks:"
156
+
157
+ # Accept header for provider metadata / JWKS / token-endpoint fetches.
158
+ _ACCEPT_JSON = "application/json"
159
+
160
+
161
+ class OIDCError(Exception):
162
+ """Base error for the SSO flow, carrying a stable machine code + HTTP status.
163
+
164
+ ``code`` is the non-sensitive identifier surfaced to the SPA (in a redirect
165
+ query param) or returned in a JSON body. It never carries PII or token
166
+ material. (The ``OIDC`` prefix is retained from ADR-0187 for continuity even
167
+ though the hierarchy now also covers GitHub OAuth2.)
168
+ """
169
+
170
+ code = "oidc_error"
171
+ http_status = 400
172
+
173
+ def __init__(
174
+ self, message: str = "", *, code: str | None = None, http_status: int | None = None
175
+ ) -> None:
176
+ super().__init__(message or (code or self.code))
177
+ if code is not None:
178
+ self.code = code
179
+ if http_status is not None:
180
+ self.http_status = http_status
181
+
182
+
183
+ class OIDCNotConfigured(OIDCError):
184
+ code = "sso_not_configured"
185
+ http_status = 400
186
+
187
+
188
+ class OIDCStateError(OIDCError):
189
+ code = "invalid_state"
190
+ http_status = 400
191
+
192
+
193
+ class OIDCTokenExchangeError(OIDCError):
194
+ code = "token_exchange_failed"
195
+ http_status = 400
196
+
197
+
198
+ class OIDCIDTokenError(OIDCError):
199
+ code = "invalid_id_token"
200
+ http_status = 400
201
+
202
+
203
+ class OIDCEmailUnverified(OIDCError):
204
+ code = "email_unverified"
205
+ http_status = 403
206
+
207
+
208
+ class OIDCNoMember(OIDCError):
209
+ code = "sso_no_member"
210
+ http_status = 403
211
+
212
+
213
+ class OIDCAccountDisabled(OIDCError):
214
+ """The identity resolved to a local account that has been deactivated (#2875).
215
+
216
+ Distinct from :class:`OIDCNoMember` because the remedy is different: the user
217
+ *is* a member, their account was switched off. Telling them to ask for an
218
+ invite (the ``sso_no_member`` copy) would send them down a path that cannot
219
+ resolve it.
220
+ """
221
+
222
+ code = "sso_account_disabled"
223
+ http_status = 403
224
+
225
+
226
+ class OIDCProviderUnreachable(OIDCError):
227
+ """Raised when discovery could not be fetched.
228
+
229
+ ``reason`` is a second, narrower axis than ``code``: ``code`` stays
230
+ ``provider_unreachable`` for every cause (it is also surfaced in the login
231
+ redirect's error query param, where callers don't branch on cause), but the
232
+ admin "Test connection" probe needs to tell an SSRF-guard refusal apart from
233
+ a DNS failure or timeout so the UI can offer the egress-allowlist remedy only
234
+ when it actually applies (#3947). ``None`` for every other cause.
235
+ """
236
+
237
+ code = "provider_unreachable"
238
+ http_status = 502
239
+
240
+ def __init__(
241
+ self,
242
+ message: str = "",
243
+ *,
244
+ code: str | None = None,
245
+ http_status: int | None = None,
246
+ reason: str | None = None,
247
+ ) -> None:
248
+ super().__init__(message, code=code, http_status=http_status)
249
+ self.reason = reason
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class LoginRedirect:
254
+ """The result of starting a login: where to send the browser, and the state key."""
255
+
256
+ authorization_url: str
257
+ state: str
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class ProviderContext:
262
+ """A resolved, enabled provider: an allauth ``SocialApp`` + its policy side row.
263
+
264
+ Wraps the two rows so the flow reads config uniformly regardless of provider
265
+ type. ``is_github`` selects the OAuth2 (no-OIDC) branch; everything else is an
266
+ OIDC IdP whose ``issuer`` is the ``server_url`` stored on the ``SocialApp``.
267
+ """
268
+
269
+ social_app: SocialApp
270
+ policy: SsoProviderPolicy
271
+
272
+ @property
273
+ def slug(self) -> str:
274
+ return self.policy.slug
275
+
276
+ @property
277
+ def is_github(self) -> bool:
278
+ return bool(self.social_app.provider == ALLAUTH_GITHUB)
279
+
280
+ @property
281
+ def display_name(self) -> str:
282
+ return str(self.social_app.name)
283
+
284
+ @property
285
+ def client_id(self) -> str:
286
+ return str(self.social_app.client_id)
287
+
288
+ def get_client_secret(self) -> str:
289
+ return self.policy.get_client_secret()
290
+
291
+ @property
292
+ def issuer(self) -> str:
293
+ """The OIDC issuer (``server_url``), or the synthetic GitHub issuer."""
294
+ if self.is_github:
295
+ return GITHUB_ISSUER
296
+ return str(self.social_app.settings.get("server_url", ""))
297
+
298
+ @property
299
+ def scopes(self) -> list[str]:
300
+ return GITHUB_SCOPES if self.is_github else OIDC_SCOPES
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Provider resolution — fail closed
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def _is_configured(ctx: ProviderContext) -> bool:
309
+ """Whether a provider is complete enough to attempt a handshake.
310
+
311
+ A half-configured provider (missing client id / secret, or — for OIDC —
312
+ missing issuer) is treated as "not usable" so the flow fails closed rather
313
+ than attempting a broken handshake.
314
+ """
315
+ if not (ctx.client_id and ctx.policy.secret_set):
316
+ return False
317
+ # OIDC needs an issuer; GitHub derives its endpoints from module constants.
318
+ return ctx.is_github or bool(ctx.issuer)
319
+
320
+
321
+ def _context_for_policy(policy: SsoProviderPolicy) -> ProviderContext:
322
+ return ProviderContext(social_app=policy.social_app, policy=policy)
323
+
324
+
325
+ def get_provider_for_slug(slug: str) -> ProviderContext | None:
326
+ """Return the enabled, fully-configured provider for ``slug``, or ``None``.
327
+
328
+ Fails closed: a disabled or half-configured provider yields ``None`` so the
329
+ login/callback flow refuses to proceed.
330
+ """
331
+ policy = (
332
+ SsoProviderPolicy.objects.select_related("social_app")
333
+ .filter(enabled=True, slug=slug)
334
+ .first()
335
+ )
336
+ if policy is None:
337
+ return None
338
+ ctx = _context_for_policy(policy)
339
+ return ctx if _is_configured(ctx) else None
340
+
341
+
342
+ def get_enabled_providers() -> list[ProviderContext]:
343
+ """Return every enabled, fully-configured provider (for the login screen)."""
344
+ contexts = [
345
+ _context_for_policy(policy)
346
+ for policy in SsoProviderPolicy.objects.select_related("social_app").filter(enabled=True)
347
+ ]
348
+ return [ctx for ctx in contexts if _is_configured(ctx)]
349
+
350
+
351
+ def domain_matches_any_enabled(email: str) -> ProviderContext | None:
352
+ """Return the first enabled provider whose domain allow-list admits ``email``.
353
+
354
+ Used by ``discover`` to answer "does this *domain* use SSO?" without leaking
355
+ account existence. ``None`` when no enabled provider admits the domain.
356
+ """
357
+ if "@" not in email:
358
+ return None
359
+ for ctx in get_enabled_providers():
360
+ if _domain_allowed(ctx.policy, email):
361
+ return ctx
362
+ return None
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Discovery (OIDC)
367
+ # ---------------------------------------------------------------------------
368
+
369
+
370
+ def _discovery_url(issuer_url: str) -> str:
371
+ return f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
372
+
373
+
374
+ def get_discovery_document(issuer_url: str) -> dict[str, Any]:
375
+ """Fetch and validate the OIDC discovery document for ``issuer_url``.
376
+
377
+ SSRF-guarded; cached for a short TTL. Validates that the document's ``issuer``
378
+ claim is identical to the configured issuer (OIDC Discovery §4.3 — a mismatch
379
+ is a misconfigured or hostile IdP) and that the endpoints the flow needs are
380
+ present.
381
+
382
+ Raises:
383
+ OIDCProviderUnreachable: discovery could not be fetched.
384
+ OIDCIDTokenError: the document is malformed or the issuer does not match.
385
+ """
386
+ cache_key = _DISCOVERY_KEY_PREFIX + issuer_url
387
+ cached = cache.get(cache_key)
388
+ if isinstance(cached, dict):
389
+ return cached
390
+
391
+ url = _discovery_url(issuer_url)
392
+ try:
393
+ resp = egress.get(url, headers={"Accept": _ACCEPT_JSON})
394
+ except egress.EgressBlocked as exc:
395
+ raise OIDCProviderUnreachable(
396
+ f"issuer URL blocked by SSRF guard: {exc}", reason="egress_blocked"
397
+ ) from exc
398
+ except (egress.EgressTimeout, egress.EgressError) as exc:
399
+ raise OIDCProviderUnreachable(f"could not reach issuer discovery: {exc}") from exc
400
+
401
+ if resp.status != 200:
402
+ raise OIDCProviderUnreachable(f"discovery returned HTTP {resp.status}")
403
+
404
+ doc = resp.json()
405
+ if not isinstance(doc, dict):
406
+ raise OIDCIDTokenError("discovery document is not valid JSON")
407
+
408
+ # The issuer in the document MUST match the configured issuer exactly.
409
+ if doc.get("issuer") != issuer_url:
410
+ raise OIDCIDTokenError("discovery issuer does not match configured issuer")
411
+
412
+ for required in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
413
+ if not doc.get(required):
414
+ raise OIDCIDTokenError(f"discovery document missing {required}")
415
+
416
+ cache.set(cache_key, doc, _DISCOVERY_TTL_SECONDS)
417
+ return doc
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # PKCE + state
422
+ # ---------------------------------------------------------------------------
423
+
424
+
425
+ def _pkce_pair() -> tuple[str, str]:
426
+ """Return ``(code_verifier, code_challenge)`` for PKCE method S256 (RFC 7636)."""
427
+ verifier = secrets.token_urlsafe(64) # 43–128 chars after urlsafe encoding
428
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
429
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
430
+ return verifier, challenge
431
+
432
+
433
+ def start_login(ctx: ProviderContext, *, redirect_uri: str) -> LoginRedirect:
434
+ """Mint single-use login state and build the provider authorization URL.
435
+
436
+ OIDC: mints ``state`` + PKCE verifier + nonce, stores the verifier/nonce/exact
437
+ redirect_uri server-side (never sent to the browser), and binds the nonce to
438
+ the ID token. GitHub: mints ``state`` and stores the redirect_uri (GitHub's
439
+ OAuth2 has no PKCE/nonce). In both cases the provider ``slug`` is stored under
440
+ the state key so the callback knows which ``SocialApp`` is completing without a
441
+ new callback path segment (ADR-0517 §3.5).
442
+ """
443
+ state = secrets.token_urlsafe(32)
444
+
445
+ if ctx.is_github:
446
+ cache.set(
447
+ _STATE_KEY_PREFIX + state,
448
+ {"slug": ctx.slug, "redirect_uri": redirect_uri},
449
+ _STATE_TTL_SECONDS,
450
+ )
451
+ params = {
452
+ "response_type": "code",
453
+ "client_id": ctx.client_id,
454
+ "redirect_uri": redirect_uri,
455
+ "scope": " ".join(ctx.scopes),
456
+ "state": state,
457
+ }
458
+ authorization_url = f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}"
459
+ return LoginRedirect(authorization_url=authorization_url, state=state)
460
+
461
+ doc = get_discovery_document(ctx.issuer)
462
+ nonce = secrets.token_urlsafe(24)
463
+ verifier, challenge = _pkce_pair()
464
+
465
+ cache.set(
466
+ _STATE_KEY_PREFIX + state,
467
+ {"slug": ctx.slug, "verifier": verifier, "nonce": nonce, "redirect_uri": redirect_uri},
468
+ _STATE_TTL_SECONDS,
469
+ )
470
+
471
+ params = {
472
+ "response_type": "code",
473
+ "client_id": ctx.client_id,
474
+ "redirect_uri": redirect_uri,
475
+ "scope": " ".join(ctx.scopes),
476
+ "state": state,
477
+ "nonce": nonce,
478
+ "code_challenge": challenge,
479
+ "code_challenge_method": "S256",
480
+ }
481
+ authorization_url = f"{doc['authorization_endpoint']}?{urlencode(params)}"
482
+ return LoginRedirect(authorization_url=authorization_url, state=state)
483
+
484
+
485
+ def consume_state(state: str) -> dict[str, Any]:
486
+ """Pop the stored login state (single-use; deleted on read).
487
+
488
+ Returns the stored ``{slug, redirect_uri, ...}``. A missing / expired /
489
+ already-consumed state raises :class:`OIDCStateError` — the callback fails
490
+ closed, which is the CSRF / replay mitigation.
491
+ """
492
+ if not state:
493
+ raise OIDCStateError("missing state")
494
+ key = _STATE_KEY_PREFIX + state
495
+ data = cache.get(key)
496
+ # Delete before doing anything else, so even concurrent callbacks race to a
497
+ # single winner (the small get/delete TOCTOU window is acceptable — the token
498
+ # endpoint also rejects a re-used authorization code).
499
+ cache.delete(key)
500
+ if not isinstance(data, dict):
501
+ raise OIDCStateError("unknown or expired state")
502
+ return data
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # Token exchange + ID-token validation (OIDC)
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ def exchange_code(
511
+ ctx: ProviderContext, doc: dict[str, Any], *, code: str, redirect_uri: str, verifier: str
512
+ ) -> dict[str, Any]:
513
+ """Exchange the authorization ``code`` + PKCE ``verifier`` for OIDC tokens.
514
+
515
+ Uses ``client_secret_post`` (confidential client) against the discovered
516
+ ``token_endpoint``, SSRF-guarded. The decrypted client secret is read from the
517
+ policy side row at call time and sent only to the IdP — never returned to a
518
+ client or logged.
519
+
520
+ Raises:
521
+ OIDCProviderUnreachable: the token endpoint could not be reached.
522
+ OIDCTokenExchangeError: the IdP returned an OAuth error or no id_token.
523
+ """
524
+ data = {
525
+ "grant_type": "authorization_code",
526
+ "code": code,
527
+ "redirect_uri": redirect_uri,
528
+ "client_id": ctx.client_id,
529
+ "client_secret": ctx.get_client_secret(),
530
+ "code_verifier": verifier,
531
+ }
532
+ try:
533
+ resp = egress.post_form(doc["token_endpoint"], data=data)
534
+ except egress.EgressBlocked as exc:
535
+ raise OIDCProviderUnreachable(f"token endpoint blocked by SSRF guard: {exc}") from exc
536
+ except (egress.EgressTimeout, egress.EgressError) as exc:
537
+ raise OIDCProviderUnreachable(f"could not reach token endpoint: {exc}") from exc
538
+
539
+ payload = resp.json()
540
+ if not isinstance(payload, dict):
541
+ raise OIDCTokenExchangeError("token endpoint returned a non-JSON body")
542
+ if resp.status != 200 or "error" in payload:
543
+ # Surface a stable code to the user; log the IdP's machine-readable error
544
+ # for ops. The logged value is the OAuth2 ``error`` *code* (RFC 6749 §5.2:
545
+ # invalid_grant, invalid_client, …) — a public enumerated value, not a
546
+ # credential — and we deliberately never log ``error_description``.
547
+ # semgrep's `python-logger-credential-disclosure` flags the `error=`
548
+ # token as a possible secret leak; this is a reviewed false positive. The
549
+ # bare `# nosemgrep` (vs the rule-id form) is deliberate: the fully
550
+ # qualified rule id is 92 chars, so an id-scoped directive cannot fit the
551
+ # 100-col limit without tripping E501/RUF100. Scope here is this one line.
552
+ logger.warning( # nosemgrep
553
+ "oidc token exchange failed: status=%s error=%s",
554
+ resp.status,
555
+ payload.get("error"),
556
+ )
557
+ raise OIDCTokenExchangeError("token exchange failed")
558
+ if not payload.get("id_token"):
559
+ raise OIDCTokenExchangeError("token response did not include an id_token")
560
+ return payload
561
+
562
+
563
+ def _fetch_jwks(jwks_uri: str, *, force_refresh: bool = False) -> tuple[dict[str, Any], bool]:
564
+ """Fetch the JWKS through the SSRF-guarded egress, cached. Returns ``(doc, was_cached)``.
565
+
566
+ PyJWT's ``PyJWKClient`` is deliberately **not** used: it fetches the JWKS with
567
+ urllib's default opener, which follows redirects and re-resolves DNS *after*
568
+ any point-in-time host check — a TOCTOU/redirect SSRF bypass of the egress
569
+ chokepoint. Fetching the JWKS ourselves via ``egress.get`` keeps every
570
+ outbound call on the redirect-disabled, allow-listed path (ADR-0187 Boundary 6
571
+ / SSRF).
572
+
573
+ ``was_cached`` is returned rather than kept private because it is what lets
574
+ the caller decide whether a ``kid`` miss is worth a second request: on a cold
575
+ cache the document in hand *is* the IdP's current one, so retrying would just
576
+ fetch the same bytes again.
577
+ """
578
+ cache_key = _JWKS_KEY_PREFIX + jwks_uri
579
+ if not force_refresh:
580
+ cached = cache.get(cache_key)
581
+ if isinstance(cached, dict):
582
+ return cached, True
583
+
584
+ try:
585
+ egress.assert_url_allowed(jwks_uri)
586
+ resp = egress.get(jwks_uri, headers={"Accept": _ACCEPT_JSON})
587
+ except egress.EgressBlocked as exc:
588
+ raise OIDCProviderUnreachable(f"jwks URL blocked by SSRF guard: {exc}") from exc
589
+ except (egress.EgressTimeout, egress.EgressError) as exc:
590
+ raise OIDCProviderUnreachable(f"could not reach jwks: {exc}") from exc
591
+ if resp.status != 200:
592
+ raise OIDCProviderUnreachable(f"jwks endpoint returned HTTP {resp.status}")
593
+
594
+ data = resp.json()
595
+ if not isinstance(data, dict) or not data.get("keys"):
596
+ raise OIDCIDTokenError("jwks document is empty or malformed")
597
+ cache.set(cache_key, data, _JWKS_TTL_SECONDS)
598
+ return data, False
599
+
600
+
601
+ def _select_signing_key(data: dict[str, Any], id_token: str) -> PyJWK | None:
602
+ """Pick the key from a JWKS document that signed ``id_token``, or ``None``.
603
+
604
+ Matches PyJWKClient's selection: the token's ``kid`` (when present) against a
605
+ signing-use key. ``public_key_use`` is "sig" or absent for signing keys.
606
+ """
607
+ try:
608
+ jwk_set = PyJWKSet.from_dict(data)
609
+ header = jwt.get_unverified_header(id_token)
610
+ except (PyJWKError, PyJWKSetError, jwt.InvalidTokenError, ValueError) as exc:
611
+ raise OIDCIDTokenError(f"could not parse jwks or token header: {exc}") from exc
612
+
613
+ kid = header.get("kid")
614
+ candidates = [
615
+ key
616
+ for key in jwk_set.keys
617
+ if getattr(key, "public_key_use", None) in ("sig", None)
618
+ and (kid is None or key.key_id == kid)
619
+ ]
620
+ return candidates[0] if candidates else None
621
+
622
+
623
+ def _signing_key_for(jwks_uri: str, id_token: str) -> PyJWK:
624
+ """Return the JWKS key that signed ``id_token``, refetching on a ``kid`` miss.
625
+
626
+ A ``kid`` the cached document does not carry is the signature of an IdP that
627
+ rotated its signing keys after we cached — a routine operator action. Failing
628
+ outright there would break every login until the TTL expired even though the
629
+ correct key is one request away, so a miss forces exactly one cache-bypassing
630
+ refetch before giving up. The retry is bounded to the cached case (see
631
+ ``_fetch_jwks``) so an unknown ``kid`` cannot be replayed to drive two
632
+ outbound requests per attempt.
633
+ """
634
+ data, was_cached = _fetch_jwks(jwks_uri)
635
+ key = _select_signing_key(data, id_token)
636
+ if key is None and was_cached:
637
+ data, _ = _fetch_jwks(jwks_uri, force_refresh=True)
638
+ key = _select_signing_key(data, id_token)
639
+ if key is None:
640
+ raise OIDCIDTokenError("no matching signing key in jwks")
641
+ return key
642
+
643
+
644
+ def validate_id_token(
645
+ ctx: ProviderContext, doc: dict[str, Any], id_token: str, *, expected_nonce: str
646
+ ) -> dict[str, Any]:
647
+ """Verify the ID token's signature and claims, returning the claim set.
648
+
649
+ Verifies the RS256/ES256 signature against the discovered JWKS (fetched
650
+ through the SSRF-guarded egress, never PyJWKClient's own socket), and enforces
651
+ ``iss == issuer``, ``aud == client_id``, ``exp``/``iat`` presence, and
652
+ ``nonce`` equality with the value bound at login. Time claims are checked with
653
+ ``_ID_TOKEN_LEEWAY_SECONDS`` of clock-skew tolerance. We keep this rather than
654
+ delegating to allauth's verification (control 4). Any failure raises
655
+ :class:`OIDCIDTokenError`.
656
+ """
657
+ signing_key = _signing_key_for(doc["jwks_uri"], id_token)
658
+
659
+ try:
660
+ claims: dict[str, Any] = jwt.decode(
661
+ id_token,
662
+ signing_key.key,
663
+ algorithms=_ALLOWED_ID_TOKEN_ALGS,
664
+ audience=ctx.client_id,
665
+ issuer=ctx.issuer,
666
+ leeway=_ID_TOKEN_LEEWAY_SECONDS,
667
+ options={"require": ["exp", "iat", "aud", "iss"]},
668
+ )
669
+ except jwt.InvalidTokenError as exc:
670
+ raise OIDCIDTokenError(f"id token validation failed: {exc}") from exc
671
+
672
+ # ``nonce`` binds this ID token to *our* login request (replay defense). PyJWT
673
+ # does not check it, so we enforce equality with the value stored under state.
674
+ if not expected_nonce or claims.get("nonce") != expected_nonce:
675
+ raise OIDCIDTokenError("id token nonce mismatch")
676
+
677
+ return claims
678
+
679
+
680
+ # ---------------------------------------------------------------------------
681
+ # GitHub OAuth2 (no OIDC discovery / JWKS / id_token) — ADR-0517 §3.2
682
+ # ---------------------------------------------------------------------------
683
+
684
+
685
+ def exchange_github_code(ctx: ProviderContext, *, code: str, redirect_uri: str) -> str:
686
+ """Exchange a GitHub authorization ``code`` for an access token (SSRF-guarded).
687
+
688
+ GitHub's OAuth2 has no PKCE/id_token; the token endpoint returns an opaque
689
+ ``access_token`` used to call the user API. The decrypted client secret is read
690
+ from the policy side row at call time and sent only to GitHub.
691
+
692
+ Raises:
693
+ OIDCProviderUnreachable: the token endpoint could not be reached.
694
+ OIDCTokenExchangeError: GitHub returned an OAuth error or no access_token.
695
+ """
696
+ data = {
697
+ "grant_type": "authorization_code",
698
+ "code": code,
699
+ "redirect_uri": redirect_uri,
700
+ "client_id": ctx.client_id,
701
+ "client_secret": ctx.get_client_secret(),
702
+ }
703
+ try:
704
+ resp = egress.post_form(GITHUB_TOKEN_URL, data=data)
705
+ except egress.EgressBlocked as exc:
706
+ raise OIDCProviderUnreachable(
707
+ f"github token endpoint blocked by SSRF guard: {exc}"
708
+ ) from exc
709
+ except (egress.EgressTimeout, egress.EgressError) as exc:
710
+ raise OIDCProviderUnreachable(f"could not reach github token endpoint: {exc}") from exc
711
+
712
+ payload = resp.json()
713
+ if not isinstance(payload, dict):
714
+ raise OIDCTokenExchangeError("github token endpoint returned a non-JSON body")
715
+ if resp.status != 200 or "error" in payload:
716
+ logger.warning( # nosemgrep
717
+ "github token exchange failed: status=%s error=%s",
718
+ resp.status,
719
+ payload.get("error"),
720
+ )
721
+ raise OIDCTokenExchangeError("github token exchange failed")
722
+ access_token = payload.get("access_token")
723
+ if not access_token or not isinstance(access_token, str):
724
+ raise OIDCTokenExchangeError("github token response did not include an access_token")
725
+ return str(access_token)
726
+
727
+
728
+ def _github_get(url: str, access_token: str) -> egress.EgressResponse:
729
+ headers = {
730
+ "Authorization": f"token {access_token}",
731
+ "Accept": "application/vnd.github+json",
732
+ }
733
+ try:
734
+ return egress.get(url, headers=headers)
735
+ except egress.EgressBlocked as exc:
736
+ raise OIDCProviderUnreachable(f"github API blocked by SSRF guard: {exc}") from exc
737
+ except (egress.EgressTimeout, egress.EgressError) as exc:
738
+ raise OIDCProviderUnreachable(f"could not reach github API: {exc}") from exc
739
+
740
+
741
+ def fetch_github_identity(ctx: ProviderContext, access_token: str) -> dict[str, Any]:
742
+ """Fetch the GitHub user identity and normalize it into OIDC-shaped claims.
743
+
744
+ Calls ``GET /user`` (profile) and ``GET /user/emails`` (to pick the verified
745
+ primary email — GitHub's ``email_verified`` analog is the ``verified`` flag on
746
+ the primary entry) through egress, and — if the policy restricts an org —
747
+ verifies membership fail-closed. Returns a claims dict mirroring the OIDC
748
+ shape (``sub``/``email``/``email_verified``/``given_name``) so ``resolve_user``
749
+ is provider-agnostic. ``sub`` is the GitHub numeric user id as a string
750
+ (stable; never the mutable login/email).
751
+
752
+ Raises:
753
+ OIDCIDTokenError: the ``/user`` response was malformed or had no id.
754
+ OIDCEmailUnverified: no verified primary email is available.
755
+ OIDCNoMember: org restriction set and the user is not a member.
756
+ OIDCProviderUnreachable: the GitHub API could not be reached.
757
+ """
758
+ resp = _github_get(GITHUB_USER_URL, access_token)
759
+ if resp.status != 200:
760
+ raise OIDCTokenExchangeError(f"github /user returned HTTP {resp.status}")
761
+ profile = resp.json()
762
+ if not isinstance(profile, dict) or not profile.get("id"):
763
+ raise OIDCIDTokenError("github /user response is malformed or has no id")
764
+
765
+ # Optional org restriction — fail closed if the user is not a member.
766
+ org = (ctx.policy.github_org or "").strip()
767
+ if org:
768
+ login = str(profile.get("login") or "")
769
+ if not login or not _github_user_in_org(access_token, org=org, username=login):
770
+ raise OIDCNoMember("github user is not a member of the required organization")
771
+
772
+ email, email_verified = _github_primary_email(access_token, profile)
773
+
774
+ name = str(profile.get("name") or "")
775
+ given_name = name.split(" ", 1)[0] if name else ""
776
+ family_name = name.split(" ", 1)[1] if " " in name else ""
777
+
778
+ return {
779
+ "sub": str(profile["id"]),
780
+ "email": email,
781
+ "email_verified": email_verified,
782
+ "given_name": given_name,
783
+ "family_name": family_name,
784
+ }
785
+
786
+
787
+ def _github_primary_email(access_token: str, profile: dict[str, Any]) -> tuple[str, bool]:
788
+ """Return ``(email, verified)`` from the user's verified primary GitHub email.
789
+
790
+ Prefers ``GET /user/emails`` (authoritative on primary + verified). Falls back
791
+ to the profile's public ``email`` only when the emails endpoint is unavailable,
792
+ in which case ``verified`` is unknown → reported ``False`` so the fail-closed
793
+ verified-email gate rejects it.
794
+ """
795
+ resp = _github_get(GITHUB_EMAILS_URL, access_token)
796
+ if resp.status == 200:
797
+ emails = resp.json()
798
+ if isinstance(emails, list):
799
+ for entry in emails:
800
+ if isinstance(entry, dict) and entry.get("primary"):
801
+ return str(entry.get("email") or ""), bool(entry.get("verified"))
802
+ # Fallback: the public profile email, treated as unverified (fail closed).
803
+ return str(profile.get("email") or ""), False
804
+
805
+
806
+ def _github_user_in_org(access_token: str, *, org: str, username: str) -> bool:
807
+ """Whether ``username`` is a member of GitHub org ``org`` (fail closed).
808
+
809
+ Uses ``GET /orgs/{org}/members/{username}`` — 204 = member, anything else
810
+ (404/302/403) = not a member / not visible. Any transport error propagates as
811
+ :class:`OIDCProviderUnreachable` from ``_github_get``, so the caller fails
812
+ closed rather than admitting on an ambiguous result.
813
+ """
814
+ resp = _github_get(f"https://api.github.com/orgs/{org}/members/{username}", access_token)
815
+ return resp.status == 204
816
+
817
+
818
+ # ---------------------------------------------------------------------------
819
+ # Identity resolution + account rules
820
+ # ---------------------------------------------------------------------------
821
+
822
+
823
+ def _domain_allowed(policy: SsoProviderPolicy, email: str) -> bool:
824
+ """Whether ``email``'s domain is in the policy allow-list.
825
+
826
+ Empty allow-list = fail closed (no domain permitted). This forces an admin to
827
+ name the domains that may sign in / be created before any account is linked,
828
+ and is the takeover gate alongside ``email_verified``.
829
+ """
830
+ domains = [d.lower() for d in (policy.allowed_email_domains or [])]
831
+ if not domains:
832
+ return False
833
+ domain = email.rsplit("@", 1)[-1].lower()
834
+ return domain in domains
835
+
836
+
837
+ def _unique_username(email: str) -> str:
838
+ """Derive a unique username for a newly auto-created SSO user.
839
+
840
+ The local part of the email is the human-friendly base; a short random suffix
841
+ is appended on collision so two ``alice@`` from different domains (or a
842
+ pre-existing username) never clash.
843
+ """
844
+ base = (email.split("@", 1)[0] or "user")[:140]
845
+ candidate = base
846
+ while User.objects.filter(username=candidate).exists():
847
+ candidate = f"{base}-{secrets.token_hex(3)}"
848
+ return candidate
849
+
850
+
851
+ def _require_active(user: Any, ctx: ProviderContext) -> Any:
852
+ """Refuse a resolved-but-deactivated account before a session is minted (#2875).
853
+
854
+ ``User.is_active = False`` **is** the deactivation mechanism —
855
+ ``workspace/views.py::_apply_member_status_change`` sets it as the thing that
856
+ stops a member logging in — and every other credential path in the auth chain
857
+ honors it: simplejwt rejects an inactive user, DRF's ``SessionAuthentication``
858
+ checks the flag, and the Personal Access Token authenticator was taught to in
859
+ #2832. The SSO callback was the one login path with no reference to it at all,
860
+ so a revoked account ran the entire handshake and was issued a **fresh refresh
861
+ token after the admin revoked it**.
862
+
863
+ ``JWTAuthentication`` refuses the inactive user on every subsequent request, so
864
+ this is defense in depth rather than privilege escalation. It still has to be
865
+ fixed here: a login that reports success and is then 401ed on every call is
866
+ indistinguishable from a broken install, and the refusal belongs where the
867
+ credential is issued rather than where it is spent.
868
+
869
+ Only the two branches that resolve a **pre-existing** user need this. The
870
+ auto-create branch builds the user in this request, so it is active by
871
+ construction — guarding it would be dead code.
872
+
873
+ The refusal is **logged, not audited**. Repeated SSO attempts against a revoked
874
+ account is exactly the signal an operator wants after an off-boarding, and
875
+ before this fix there was nothing distinctive to record because the login simply
876
+ succeeded. It is a log line rather than an ``AuditEvent`` row because the caller
877
+ is unauthenticated at this point: an audit row per attempt would let anyone who
878
+ can drive the callback write unbounded rows, the same amplification the Personal
879
+ Access Token authenticator bounds deliberately. The record carries the user's
880
+ primary key, never their email or any token material.
881
+ """
882
+ if not user.is_active:
883
+ logger.warning(
884
+ "sso login refused: account is deactivated (user_id=%s provider=%s issuer=%s)",
885
+ user.pk,
886
+ ctx.slug,
887
+ ctx.issuer,
888
+ )
889
+ raise OIDCAccountDisabled("account is deactivated")
890
+ return user
891
+
892
+
893
+ def _workspace_role_of(user: Any) -> int | None:
894
+ """The user's current workspace role, or ``None`` when they hold no membership.
895
+
896
+ Recorded on ``sso_account_linked`` so the row answers *what access did this federated
897
+ identity just get?* without a second lookup against state that may since have moved.
898
+ ``None`` is a real answer, not a failure: an account can exist with no membership (a
899
+ superuser holding implicit ownership, or a user provisioned outside the workspace).
900
+ """
901
+ membership = (
902
+ WorkspaceMembership.objects.filter(user=user, is_deleted=False)
903
+ .values_list("role", flat=True)
904
+ .first()
905
+ )
906
+ return int(membership) if membership is not None else None
907
+
908
+
909
+ def _audit_account_linked(user: Any, ctx: ProviderContext, *, subject: str, email: str) -> None:
910
+ """Record that an IdP identity bound itself to a pre-existing local account (#3552).
911
+
912
+ Branch 3 of :func:`resolve_user`. Its sibling — the auto-create branch — has written
913
+ ``MEMBER_ADDED`` since the feature shipped, so the path that mints a *new* account was
914
+ legible while the path that hands a federated credential to an account that already
915
+ has one was not (ADR-1120).
916
+
917
+ Written inside :func:`resolve_user`'s transaction, so a link that fails afterwards
918
+ cannot leave a row claiming it happened. The actor is the **linked user**, matching the
919
+ ``member_added`` / ``invite_accepted`` convention for this unauthenticated join path:
920
+ there is no administrator in the request to attribute it to.
921
+
922
+ The metadata is enumerated deliberately and is **not** derived from the ``claims``
923
+ dict beyond ``subject``. ``record_audit_event`` stores ``metadata`` verbatim, and for
924
+ an OIDC provider ``claims`` is whatever the IdP chose to sign — group lists, phone
925
+ numbers, the nonce, sometimes an embedded token. ``subject`` is the durable
926
+ ``(issuer, subject)`` half an operator needs to correlate this row against their IdP's
927
+ own logs, and is already stored on the ``SocialAccount``.
928
+ """
929
+ # Lazily imported to avoid a workspace→sso import cycle at module load, matching the
930
+ # auto-create branch below and ``projects/views.py::_record_project_audit_event``.
931
+ from trueppm_api.apps.workspace.models import AuditEventType
932
+ from trueppm_api.apps.workspace.services import record_audit_event
933
+
934
+ record_audit_event(
935
+ event_type=AuditEventType.SSO_ACCOUNT_LINKED,
936
+ actor=user,
937
+ target_type="user",
938
+ target_id=user.pk,
939
+ target_label=email,
940
+ metadata={
941
+ "via": "sso",
942
+ "provider": ctx.slug,
943
+ "issuer": ctx.issuer,
944
+ "subject": subject,
945
+ "role": _workspace_role_of(user),
946
+ },
947
+ )
948
+
949
+
950
+ def _render_sso_account_linked_email(
951
+ *, greeting_name: str, provider_name: str, linked_at: Any
952
+ ) -> tuple[str, str, str]:
953
+ """Render (subject, plain-text body, html body) for the account-linked notice (#3554).
954
+
955
+ Follows the inline-render idiom used by password-reset/invites/notifications (no
956
+ templates directory exists in the API). Names the provider and the approximate time
957
+ of the link so the recipient — who, unlike a password-reset request, is guaranteed to
958
+ already hold this mailbox — has enough to judge whether the sign-in was theirs, without
959
+ a link or token that would need its own expiry/replay handling.
960
+
961
+ Takes plain strings rather than the ``User``/``ProviderContext`` objects they were
962
+ read from — see :func:`_send_sso_account_linked_email`.
963
+ """
964
+ subject = "A new sign-in method was added to your TruePPM account"
965
+ when = linked_at.strftime("%Y-%m-%d %H:%M UTC")
966
+
967
+ text_body = "\n".join(
968
+ [
969
+ f"Hi {greeting_name},",
970
+ "",
971
+ f"Your TruePPM account was just linked to a {provider_name} sign-in, at "
972
+ f"{when}. From now on you can sign in with either your password or "
973
+ f"{provider_name}.",
974
+ "",
975
+ "If this was you, no action is needed.",
976
+ "",
977
+ "If you did not do this, someone who controls a mailbox matching your "
978
+ "account's email address may have signed in through your identity "
979
+ "provider. Contact your workspace administrator right away so they can "
980
+ "review your account.",
981
+ ]
982
+ )
983
+
984
+ html_body = "".join(
985
+ [
986
+ f"<p>Hi {escape(greeting_name)},</p>",
987
+ f"<p>Your TruePPM account was just linked to a {escape(provider_name)} "
988
+ f"sign-in, at {escape(when)}. From now on you can sign in with either "
989
+ f"your password or {escape(provider_name)}.</p>",
990
+ "<p>If this was you, no action is needed.</p>",
991
+ "<p>If you did not do this, someone who controls a mailbox matching your "
992
+ "account's email address may have signed in through your identity "
993
+ "provider. Contact your workspace administrator right away so they can "
994
+ "review your account.</p>",
995
+ ]
996
+ )
997
+ return subject, text_body, html_body
998
+
999
+
1000
+ def _send_sso_account_linked_email(
1001
+ *, user_id: Any, recipient: str, greeting_name: str, provider_name: str, linked_at: Any
1002
+ ) -> bool:
1003
+ """Best-effort notify the linked account that an SSO identity was just bound to it (#3554).
1004
+
1005
+ Called only from :func:`resolve_user` branch 3 (first-time link by verified email),
1006
+ deferred with ``transaction.on_commit`` by the caller so a link that is rolled back
1007
+ (e.g. :func:`_require_active` raising) never sends a notice for a binding that was
1008
+ never actually created. Never called on branch 1 (repeat login by durable
1009
+ ``(issuer, subject)``), so an established SSO user is not re-notified on every sign-in.
1010
+
1011
+ Takes plain strings, not the ``User``/``ProviderContext`` the caller read them from:
1012
+ an ``on_commit`` closure that instead captured those live objects would be reading
1013
+ them well after the transaction that produced them closed, and — though this one
1014
+ fires synchronously in the same request, not via Celery — nothing about the
1015
+ ``on_commit`` contract promises that stays true, so the closure must not depend on it.
1016
+ The caller resolves ``greeting_name``/``recipient``/``provider_name`` before
1017
+ registering this call.
1018
+
1019
+ Mirrors ``core.password_reset.send_password_reset_email``'s failure posture: a mail
1020
+ transport that is unconfigured, unusable, or errors mid-send is logged and swallowed
1021
+ rather than raised. The caller runs this from an ``on_commit`` hook fired at the tail
1022
+ of the SSO callback view, so an unhandled exception here would surface as a 500 on a
1023
+ login that has, from the user's perspective, already succeeded — worse than a
1024
+ best-effort notice that occasionally does not arrive.
1025
+ """
1026
+ if not recipient:
1027
+ return False
1028
+
1029
+ from trueppm_api.apps.notifications.delivery_limits import (
1030
+ note_unbudgeted_send,
1031
+ workspace_throttle_per_min,
1032
+ )
1033
+ from trueppm_api.apps.notifications.email_backend import (
1034
+ resolve_email_connection,
1035
+ resolve_from_email,
1036
+ resolve_reply_to,
1037
+ )
1038
+
1039
+ try:
1040
+ connection = resolve_email_connection()
1041
+ except Exception:
1042
+ logger.warning("sso account-linked email: mail transport unusable")
1043
+ return False
1044
+
1045
+ # A one-time security notice tied to a specific login event: like the password-reset
1046
+ # email, it charges the shared per-minute delivery budget but is never refused by it
1047
+ # (#2887 item 3) — a batch of invites spending the minute must not suppress a notice
1048
+ # about who can now sign in to this account.
1049
+ note_unbudgeted_send(workspace_throttle_per_min())
1050
+
1051
+ subject, text_body, html_body = _render_sso_account_linked_email(
1052
+ greeting_name=greeting_name, provider_name=provider_name, linked_at=linked_at
1053
+ )
1054
+ msg = EmailMultiAlternatives(
1055
+ subject=subject,
1056
+ body=text_body,
1057
+ from_email=resolve_from_email(),
1058
+ to=[recipient],
1059
+ reply_to=resolve_reply_to() or None,
1060
+ connection=connection,
1061
+ )
1062
+ msg.attach_alternative(html_body, "text/html")
1063
+ try:
1064
+ msg.send(fail_silently=False)
1065
+ except Exception:
1066
+ logger.warning("sso account-linked email failed for user %s", user_id, exc_info=True)
1067
+ return False
1068
+ return True
1069
+
1070
+
1071
+ @transaction.atomic
1072
+ def resolve_user(ctx: ProviderContext, claims: dict[str, Any]) -> tuple[Any, bool]:
1073
+ """Resolve (and possibly create/link) the local user for a validated identity.
1074
+
1075
+ Resolution order (ADR-0517 §3.2, preserving ADR-0187 §2):
1076
+
1077
+ 1. **Durable key** — an existing ``SocialAccount`` on ``(provider=slug, uid=sub)``
1078
+ **whose stored issuer matches the current provider issuer** wins outright.
1079
+ Later logins always route here, never by email. The provider slug
1080
+ disambiguates distinct OIDC IdPs that share allauth's ``openid_connect``
1081
+ type; the ``(issuer, subject)`` pair is the real durable key (ADR-0187 §2 /
1082
+ ADR-0517 §3.1). Requiring the issuer match on resolve — not just storing it
1083
+ at bind time — closes a cross-issuer ``sub``-collision takeover: an admin who
1084
+ repoints a slug to a new issuer must not let a user at the new issuer whose
1085
+ ``sub`` collides with an old binding resolve to the wrong local user. On
1086
+ issuer mismatch we **fail closed** (``OIDCNoMember``) rather than fall
1087
+ through, because allauth's ``(provider, uid)`` unique key means a second
1088
+ binding for the same ``(slug, subject)`` under the new issuer cannot be
1089
+ created anyway.
1090
+ 2. **Verified email** — require ``email`` present and ``email_verified is True``
1091
+ and the domain in the allow-list; otherwise fail closed.
1092
+ 3. **Link** — exactly one existing local user with that email → bind a new
1093
+ ``SocialAccount`` and sign in. (Ambiguous: 0 or >1 → not a link; a superuser,
1094
+ staff, or workspace Owner/Admin match is refused outright, #4629, #4651.) A best-effort
1095
+ notice email is deferred to commit (#3554) so the local account learns a new
1096
+ sign-in method was just added — this is the one branch that hands a federated
1097
+ credential to an account whose owner may not have expected it.
1098
+ 4. **Auto-create** — no existing user, ``auto_create_members`` on → create the
1099
+ user + one ``WorkspaceMembership`` at the resolved role, bind the identity.
1100
+ 5. Otherwise → :class:`OIDCNoMember`.
1101
+
1102
+ Branches 1 and 3 additionally refuse a **deactivated** account with
1103
+ :class:`OIDCAccountDisabled` (see :func:`_require_active`, #2875) — the SSO
1104
+ callback was the one login path that never consulted ``is_active``.
1105
+
1106
+ Returns ``(user, created)``. Wrapped in a transaction so a half-created
1107
+ user/membership/identity never persists.
1108
+ """
1109
+ from allauth.socialaccount.models import SocialAccount
1110
+
1111
+ provider_key = ctx.slug
1112
+ issuer = ctx.issuer
1113
+ subject = str(claims.get("sub") or "")
1114
+ if not subject:
1115
+ raise OIDCIDTokenError("identity has no subject")
1116
+
1117
+ # 1. Durable identity — resolve by the stable (issuer, subject) pair, never the
1118
+ # mutable email. The (provider=slug, uid=subject) row is only trusted when its
1119
+ # stored issuer matches the current provider issuer: a slug repointed to a new
1120
+ # issuer must not resolve a colliding ``sub`` to the old issuer's user (takeover
1121
+ # defense). On mismatch we **fail closed** below rather than falling through to
1122
+ # the verified-email path, because allauth's (provider, uid) unique key means a
1123
+ # second binding under the new issuer could not be created anyway. For GitHub
1124
+ # the issuer is the fixed GITHUB_ISSUER constant, so bindings still match.
1125
+ account = (
1126
+ SocialAccount.objects.select_related("user")
1127
+ .filter(provider=provider_key, uid=subject)
1128
+ .first()
1129
+ )
1130
+ if account is not None:
1131
+ if account.extra_data.get("iss") == issuer:
1132
+ return _require_active(account.user, ctx), False
1133
+ # (provider=slug, uid=subject) is already bound to a DIFFERENT issuer. We
1134
+ # must never resolve to the old issuer's user (cross-issuer sub-collision
1135
+ # takeover), and — because allauth's SocialAccount key is (provider, uid),
1136
+ # with the issuer disambiguator only in extra_data — we cannot create a
1137
+ # second binding for the same (slug, subject) under the new issuer either.
1138
+ # Fail closed.
1139
+ raise OIDCNoMember("subject is bound to a different issuer for this provider")
1140
+
1141
+ # 2. Verified-email gate.
1142
+ email = (claims.get("email") or "").strip()
1143
+ if not email or claims.get("email_verified") is not True:
1144
+ raise OIDCEmailUnverified("IdP did not assert a verified email")
1145
+ if not _domain_allowed(ctx.policy, email):
1146
+ raise OIDCNoMember("email domain is not permitted")
1147
+
1148
+ # 3. Link to an existing local account (only when unambiguous).
1149
+ existing = list(User.objects.filter(email__iexact=email)[:2])
1150
+ if len(existing) == 1:
1151
+ # Checked BEFORE the binding is written: a refused login must not leave a
1152
+ # SocialAccount row behind, or reactivating the member later would silently
1153
+ # skip the verified-email link step that created it.
1154
+ _require_active(existing[0], ctx)
1155
+ # An IdP-asserted email is a claim about the *mailbox*, not proof the caller
1156
+ # owns a privileged local account. Auto-linking is therefore refused for
1157
+ # ``is_superuser`` and ``is_staff`` accounts (#4629): a squatter who pre-claimed
1158
+ # an address that later became privileged (the ``create_admin`` bootstrap email)
1159
+ # would otherwise be signed in as that admin on first IdP assertion, and
1160
+ # ``create_admin`` clearing SocialAccount rows only forces a re-link. ``is_staff``
1161
+ # is included because it grants Django-admin access, the other elevated tier.
1162
+ # Fail closed with the same refusal as the ambiguous-email case, before any
1163
+ # binding is written; a privileged account links only via an explicit,
1164
+ # authenticated session (or an operator), never by email match.
1165
+ #
1166
+ # The same reasoning covers the in-app top tiers (#4651): a workspace OWNER or
1167
+ # ADMIN promoted in-app is neither superuser nor staff, yet whoever controls the
1168
+ # matching mailbox at the IdP would otherwise sign in as that Owner. This takes
1169
+ # up the block-for-admins option #3554 deferred. ``>= ADMIN`` also covers any
1170
+ # Enterprise role registered above ADMIN.
1171
+ role = workspace_role_for_user(existing[0])
1172
+ if (
1173
+ existing[0].is_superuser
1174
+ or existing[0].is_staff
1175
+ or (role is not None and role >= WorkspaceRole.ADMIN)
1176
+ ):
1177
+ logger.warning(
1178
+ "sso verified-email auto-link refused for privileged user %s", existing[0].pk
1179
+ )
1180
+ raise OIDCNoMember("verified-email link to a privileged account is not permitted")
1181
+ SocialAccount.objects.create(
1182
+ user=existing[0], provider=provider_key, uid=subject, extra_data={"iss": issuer}
1183
+ )
1184
+ _audit_account_linked(existing[0], ctx, subject=subject, email=email)
1185
+ # Notify the local account that an SSO identity was just bound to it (#3554).
1186
+ # Deferred to commit so a link that unwinds (e.g. a later error in this same
1187
+ # transaction) never sends a notice for a binding that was never persisted.
1188
+ # Every value the closure needs is resolved to a plain string/datetime *now*,
1189
+ # before it is registered — not read from `existing[0]`/`ctx` inside the lambda —
1190
+ # so the callback cannot depend on either row still reflecting live state (or
1191
+ # still existing at all) by the time it actually runs.
1192
+ notify_user_id = existing[0].pk
1193
+ notify_recipient = existing[0].email
1194
+ notify_greeting_name = existing[0].get_short_name() or existing[0].get_username()
1195
+ notify_provider_name = ctx.display_name
1196
+ notify_linked_at = timezone.now()
1197
+ transaction.on_commit(
1198
+ lambda: _send_sso_account_linked_email(
1199
+ user_id=notify_user_id,
1200
+ recipient=notify_recipient,
1201
+ greeting_name=notify_greeting_name,
1202
+ provider_name=notify_provider_name,
1203
+ linked_at=notify_linked_at,
1204
+ )
1205
+ )
1206
+ return existing[0], False
1207
+ if len(existing) > 1:
1208
+ # Email is not unique in Django's auth model; an ambiguous match must not
1209
+ # silently pick one account (takeover risk). Fail closed.
1210
+ raise OIDCNoMember("ambiguous account for email")
1211
+
1212
+ # 4. Auto-create.
1213
+ if not ctx.policy.auto_create_members:
1214
+ raise OIDCNoMember("no account and auto-create disabled")
1215
+
1216
+ user = User.objects.create(
1217
+ username=_unique_username(email),
1218
+ email=email,
1219
+ first_name=(claims.get("given_name") or "")[:150],
1220
+ last_name=(claims.get("family_name") or "")[:150],
1221
+ )
1222
+ # SSO-only account — no usable local password. Account-linking (branch 3)
1223
+ # preserves an existing user's password; freshly created users have none.
1224
+ user.set_unusable_password()
1225
+ user.save(update_fields=["password"])
1226
+
1227
+ role = oidc_role_for(claims, ctx.policy)
1228
+ WorkspaceMembership.objects.create(
1229
+ workspace=ctx.policy.workspace,
1230
+ user=user,
1231
+ role=role,
1232
+ status=MemberStatus.ACTIVE,
1233
+ )
1234
+ SocialAccount.objects.create(
1235
+ user=user, provider=provider_key, uid=subject, extra_data={"iss": issuer}
1236
+ )
1237
+
1238
+ # Audit the join. Imported lazily to avoid a workspace→sso import cycle at
1239
+ # module load; the helper writes the row inside this transaction (rolls back
1240
+ # with us) and fans out the signal on commit.
1241
+ from trueppm_api.apps.workspace.models import AuditEventType
1242
+ from trueppm_api.apps.workspace.services import record_audit_event
1243
+
1244
+ record_audit_event(
1245
+ event_type=AuditEventType.MEMBER_ADDED,
1246
+ actor=user,
1247
+ target_type="user",
1248
+ target_id=user.pk,
1249
+ target_label=email,
1250
+ # ``role`` added in #3552: ``administration/audit-log.md`` has documented
1251
+ # ``member_added`` as carrying it since the verb shipped, and this emission site
1252
+ # omitted it while having just computed it — leaving "what access did this
1253
+ # federated identity get?" unanswerable from the row that grants it.
1254
+ metadata={"via": "sso", "issuer": issuer, "provider": provider_key, "role": int(role)},
1255
+ )
1256
+ return user, True
1257
+
1258
+
1259
+ # ---------------------------------------------------------------------------
1260
+ # Removal impact — what an admin is about to break (#2874)
1261
+ # ---------------------------------------------------------------------------
1262
+
1263
+
1264
+ @dataclass(frozen=True)
1265
+ class RemovalImpact:
1266
+ """What removing one provider costs, surfaced to the admin before they confirm.
1267
+
1268
+ ``locked_out_accounts`` is the number that matters: members whose **only**
1269
+ sign-in method is this provider's binding.
1270
+ """
1271
+
1272
+ linked_accounts: int
1273
+ locked_out_accounts: int
1274
+
1275
+
1276
+ def removal_impact(slugs: list[str]) -> dict[str, RemovalImpact]:
1277
+ """Per-slug removal impact for every slug in ``slugs`` (#2874).
1278
+
1279
+ Why this has to exist: the DELETE path purges the provider's ``SocialAccount``
1280
+ rows, and that purge is **load-bearing** — it is exactly what lets users
1281
+ re-link by verified email when an admin follows the documented remove-and-re-add
1282
+ route to move a provider to a new issuer. That holds for ordinary members only:
1283
+ workspace Owner/Admin, superuser, and staff accounts are refused by branch 3 (#4629,
1284
+ #4651), so an SSO-created one needs an operator-added link or a password.
1285
+
1286
+ Keeping the rows instead would leave
1287
+ every binding stamped with the old ``iss``, and ``resolve_user`` would fail
1288
+ them closed forever.
1289
+
1290
+ The cost of that purge is a member who has no other credential.
1291
+ :func:`resolve_user` calls ``set_unusable_password()`` on every JIT-created
1292
+ user, and ``core/password_reset.py`` gates the request path on
1293
+ ``has_usable_password()`` — so an SSO-only account receives the
1294
+ anti-enumeration 200 and no email. Until the provider (or another configured
1295
+ provider that admits their email domain) is set up again they cannot sign in by
1296
+ any route, and no admin set-password endpoint exists. That is a real,
1297
+ unrecoverable-by-self-service state, so the admin has to be given the number
1298
+ before they confirm it.
1299
+
1300
+ "No other sign-in method" means: no usable local password, **and** no surviving
1301
+ binding to any *other* still-configured provider. Deactivated accounts are
1302
+ excluded — they cannot sign in either way, so counting them would inflate the
1303
+ warning and train admins to ignore it.
1304
+
1305
+ Costs **two queries regardless of how many providers are configured**: the
1306
+ bindings and the credential-less user set are each fetched once and paired in
1307
+ Python, rather than running a per-slug aggregate that would make the admin list
1308
+ page's query count scale with the registry.
1309
+ """
1310
+ from allauth.socialaccount.models import SocialAccount
1311
+ from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
1312
+ from django.db.models import Q
1313
+
1314
+ wanted = list(slugs)
1315
+ if not wanted:
1316
+ return {}
1317
+
1318
+ # Scope the bindings to *configured* providers: a leftover row for a provider
1319
+ # that no longer exists is not a sign-in method, so it must not count as the
1320
+ # "other" credential that makes a user safe to strand.
1321
+ configured = list(SsoProviderPolicy.objects.values_list("slug", flat=True))
1322
+ bindings = list(
1323
+ SocialAccount.objects.filter(provider__in=configured).values_list("user_id", "provider")
1324
+ )
1325
+
1326
+ per_user: dict[Any, set[str]] = {}
1327
+ for user_id, provider in bindings:
1328
+ per_user.setdefault(user_id, set()).add(provider)
1329
+
1330
+ # The DB-pushable subset of ``has_usable_password()``: the two shapes this
1331
+ # codebase actually produces — ``set_unusable_password()``'s "!"-prefixed
1332
+ # sentinel and an empty column. Django additionally treats a hash no hasher can
1333
+ # parse as unusable; such a value would be missed here, which under-counts (the
1334
+ # admin is warned about fewer members than are really stranded) rather than
1335
+ # over-counts. Accepted: no code path in this repo writes a malformed hash, and
1336
+ # pulling every bound user into Python to call ``has_usable_password()`` would
1337
+ # trade a real per-request cost for a state that cannot occur.
1338
+ credential_less = set(
1339
+ User.objects.filter(
1340
+ Q(password="") | Q(password__startswith=UNUSABLE_PASSWORD_PREFIX),
1341
+ pk__in=list(per_user),
1342
+ is_active=True,
1343
+ ).values_list("pk", flat=True)
1344
+ )
1345
+
1346
+ linked = dict.fromkeys(wanted, 0)
1347
+ locked_out = dict.fromkeys(wanted, 0)
1348
+ for user_id, providers in per_user.items():
1349
+ sole_binding = len(providers) == 1
1350
+ strands = sole_binding and user_id in credential_less
1351
+ for provider in providers:
1352
+ if provider not in linked:
1353
+ continue
1354
+ linked[provider] += 1
1355
+ if strands:
1356
+ locked_out[provider] += 1
1357
+
1358
+ return {
1359
+ slug: RemovalImpact(linked_accounts=linked[slug], locked_out_accounts=locked_out[slug])
1360
+ for slug in wanted
1361
+ }
1362
+
1363
+
1364
+ # ---------------------------------------------------------------------------
1365
+ # Test connection (admin)
1366
+ # ---------------------------------------------------------------------------
1367
+
1368
+
1369
+ def check_provider_reachability(ctx: ProviderContext) -> dict[str, Any]:
1370
+ """Probe a provider's reachability for the admin "Test connection" button.
1371
+
1372
+ OIDC: fetch discovery (cache-bypassed) + confirm JWKS reachability. GitHub:
1373
+ confirm the token endpoint host is reachable (a HEAD-equivalent GET of the API
1374
+ root). Never raises — returns a structured ``{ok, ...}`` result so the admin
1375
+ page can render a clear pass/fail.
1376
+ """
1377
+ if ctx.is_github:
1378
+ return _check_github_reachability()
1379
+ return _check_oidc_reachability(ctx.issuer)
1380
+
1381
+
1382
+ def _check_oidc_reachability(issuer_url: str) -> dict[str, Any]:
1383
+ cache.delete(_DISCOVERY_KEY_PREFIX + issuer_url)
1384
+ try:
1385
+ doc = get_discovery_document(issuer_url)
1386
+ except OIDCError as exc:
1387
+ result: dict[str, Any] = {
1388
+ "ok": False,
1389
+ "issuer": issuer_url,
1390
+ "error": exc.code,
1391
+ "detail": str(exc),
1392
+ }
1393
+ reason = getattr(exc, "reason", None)
1394
+ if reason:
1395
+ result["reason"] = reason
1396
+ return result
1397
+
1398
+ jwks_uri = doc.get("jwks_uri", "")
1399
+ # Deliberately its own egress call rather than ``_fetch_jwks``: "Test
1400
+ # connection" must prove the endpoint is reachable *now* (a cached hit would
1401
+ # report a healthy provider whose keys endpoint has since died), and it must
1402
+ # not populate the login path's cache from a probe. It also keeps the probe's
1403
+ # own error taxonomy, which folds a non-200 into ``jwks_empty`` — for an admin
1404
+ # "the keys are not usable" and "the keys are not there" need the same fix.
1405
+ # ``EgressBlocked`` is caught separately from timeout/connection errors so the
1406
+ # response can carry a ``reason: "egress_blocked"`` the UI branches on (#3947) —
1407
+ # substring-matching the prose ``detail`` is brittle and the message is not a
1408
+ # stable contract.
1409
+ try:
1410
+ egress.assert_url_allowed(jwks_uri)
1411
+ jwks_resp = egress.get(jwks_uri, headers={"Accept": _ACCEPT_JSON})
1412
+ except egress.EgressBlocked as exc:
1413
+ return {
1414
+ "ok": False,
1415
+ "issuer": issuer_url,
1416
+ "error": "jwks_unreachable",
1417
+ "reason": "egress_blocked",
1418
+ "detail": str(exc),
1419
+ }
1420
+ except (egress.EgressTimeout, egress.EgressError) as exc:
1421
+ return {"ok": False, "issuer": issuer_url, "error": "jwks_unreachable", "detail": str(exc)}
1422
+
1423
+ keys = jwks_resp.json() if jwks_resp.status == 200 else None
1424
+ if not isinstance(keys, dict) or not keys.get("keys"):
1425
+ return {"ok": False, "issuer": issuer_url, "error": "jwks_empty"}
1426
+
1427
+ return {
1428
+ "ok": True,
1429
+ "issuer": issuer_url,
1430
+ "endpoints": {
1431
+ "authorization_endpoint": doc.get("authorization_endpoint", ""),
1432
+ "token_endpoint": doc.get("token_endpoint", ""),
1433
+ "jwks_uri": jwks_uri,
1434
+ },
1435
+ }
1436
+
1437
+
1438
+ def _check_github_reachability() -> dict[str, Any]:
1439
+ try:
1440
+ egress.assert_url_allowed(GITHUB_USER_URL)
1441
+ resp = egress.get(GITHUB_USER_URL)
1442
+ except egress.EgressBlocked as exc:
1443
+ # GitHub SaaS is a public host, so this only fires when the operator's own
1444
+ # egress allow-list has been tightened past the default (e.g. an explicit
1445
+ # deny-by-default policy) rather than the in-cluster-IdP case #3947 targets
1446
+ # — but the response shape stays consistent with the OIDC branch above.
1447
+ return {
1448
+ "ok": False,
1449
+ "issuer": GITHUB_ISSUER,
1450
+ "error": "github_unreachable",
1451
+ "reason": "egress_blocked",
1452
+ "detail": str(exc),
1453
+ }
1454
+ except (egress.EgressTimeout, egress.EgressError) as exc:
1455
+ return {
1456
+ "ok": False,
1457
+ "issuer": GITHUB_ISSUER,
1458
+ "error": "github_unreachable",
1459
+ "detail": str(exc),
1460
+ }
1461
+ # An unauthenticated GET /user returns 401 — that still proves the endpoint is
1462
+ # reachable (which is all "test connection" verifies before credentials are used).
1463
+ if resp.status in (200, 401):
1464
+ return {
1465
+ "ok": True,
1466
+ "issuer": GITHUB_ISSUER,
1467
+ "endpoints": {
1468
+ "authorization_endpoint": GITHUB_AUTHORIZE_URL,
1469
+ "token_endpoint": GITHUB_TOKEN_URL,
1470
+ "jwks_uri": "",
1471
+ },
1472
+ }
1473
+ return {"ok": False, "issuer": GITHUB_ISSUER, "error": "github_unreachable"}