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.
- trueppm_api/__init__.py +27 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +346 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/minter_role.py +38 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +3263 -0
- trueppm_api/apps/access/serializers.py +1007 -0
- trueppm_api/apps/access/services.py +878 -0
- trueppm_api/apps/access/signals.py +171 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +96 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1965 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +109 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +459 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +178 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +863 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +597 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +635 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +528 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +734 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1158 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +280 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +551 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1276 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +439 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +462 -0
- trueppm_api/apps/notifications/email_backend.py +350 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1148 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +1094 -0
- trueppm_api/apps/notifications/services.py +1489 -0
- trueppm_api/apps/notifications/tasks.py +762 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +919 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +566 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +993 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +537 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +541 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +458 -0
- trueppm_api/apps/projects/backfill.py +459 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +212 -0
- trueppm_api/apps/projects/config_notice.py +1330 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +132 -0
- trueppm_api/apps/projects/decisions_views.py +97 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +423 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/key_views.py +468 -0
- trueppm_api/apps/projects/keys.py +205 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migration_helpers.py +75 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
- trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
- trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
- trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +9209 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +903 -0
- trueppm_api/apps/projects/program_schedule.py +767 -0
- trueppm_api/apps/projects/program_views.py +3445 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
- trueppm_api/apps/projects/seed/exporter.py +1717 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2764 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +128 -0
- trueppm_api/apps/projects/seed/replay.py +1695 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1169 -0
- trueppm_api/apps/projects/serializers.py +12904 -0
- trueppm_api/apps/projects/services.py +7525 -0
- trueppm_api/apps/projects/share_serializers.py +142 -0
- trueppm_api/apps/projects/share_services.py +373 -0
- trueppm_api/apps/projects/share_views.py +395 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +910 -0
- trueppm_api/apps/projects/signal_privacy_views.py +485 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +288 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1244 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1979 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +261 -0
- trueppm_api/apps/projects/template_views.py +829 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1016 -0
- trueppm_api/apps/projects/utilization.py +909 -0
- trueppm_api/apps/projects/views.py +21225 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1752 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +144 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/finish_reading.py +198 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/mc_admission.py +181 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +542 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +334 -0
- trueppm_api/apps/scheduling/serializers.py +723 -0
- trueppm_api/apps/scheduling/services.py +1251 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +2248 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2908 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +446 -0
- trueppm_api/apps/sso/services.py +1473 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +369 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +548 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +272 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +488 -0
- trueppm_api/apps/sync/serializers.py +688 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +556 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +957 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +440 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +211 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +597 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +279 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
- trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
- trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +437 -0
- trueppm_api/apps/webhooks/serializers.py +326 -0
- trueppm_api/apps/webhooks/tasks.py +680 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +533 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +371 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +254 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +967 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +616 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1581 -0
- trueppm_api/asgi.py +49 -0
- trueppm_api/celery.py +40 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +934 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/demo_read_only.py +314 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +1221 -0
- trueppm_api/core/password_reset.py +513 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +1034 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +514 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/core/worker_selfheal.py +205 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2605 -0
- trueppm_api/settings/dev.py +296 -0
- trueppm_api/settings/prod.py +265 -0
- trueppm_api/urls.py +217 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0.dist-info/METADATA +273 -0
- trueppm_api-0.4.0.dist-info/RECORD +799 -0
- trueppm_api-0.4.0.dist-info/WHEEL +4 -0
- trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
- 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"}
|