trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
"""Multi-provider SSO endpoints (ADR-0517 §3.4–3.5, supersedes ADR-0187 §2–3).
|
|
2
|
+
|
|
3
|
+
Two groups:
|
|
4
|
+
|
|
5
|
+
- **Unauthenticated flow** (pre-session): ``discover`` (domain probe, no
|
|
6
|
+
enumeration leak, now returning the *list* of enabled providers), ``login``
|
|
7
|
+
(302 to the chosen IdP with state/PKCE/nonce), and ``callback`` (validate, mint
|
|
8
|
+
the existing cookie-JWT session, 302 to the SPA). The callback path is
|
|
9
|
+
**unchanged** for every provider — a ``slug`` stored in the login state (not a
|
|
10
|
+
new URL segment) disambiguates which ``SocialApp`` is completing, so the OTel
|
|
11
|
+
``code``/``state`` redaction rule and operator IdP allow-lists keep matching
|
|
12
|
+
(ADR-0517 §3.5).
|
|
13
|
+
- **Admin config** (``IsNotTokenAuthenticated`` + ``IsWorkspaceAdminStrict``): the
|
|
14
|
+
``/workspace/sso/providers/`` collection (list/create), item (get/update/delete
|
|
15
|
+
by slug), and ``test-connection``. Strict (ADMIN on *all* methods, reads
|
|
16
|
+
included) because even a GET exposes the org's IdP topology, and session/JWT-only
|
|
17
|
+
on top of that (#3551) because provider config decides who may become a member
|
|
18
|
+
and at what role — see the section comment above the collection view.
|
|
19
|
+
|
|
20
|
+
The callback never puts a token in the URL: it sets the hardened httpOnly refresh
|
|
21
|
+
cookie via the existing ``_set_refresh_cookie`` and 302s the browser to the SPA
|
|
22
|
+
completion route, which then calls the existing ``/auth/token/refresh/``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from django.conf import settings
|
|
31
|
+
from django.db import IntegrityError
|
|
32
|
+
from django.http import HttpResponse, HttpResponseRedirect
|
|
33
|
+
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
|
34
|
+
from rest_framework import status
|
|
35
|
+
from rest_framework.exceptions import PermissionDenied
|
|
36
|
+
from rest_framework.permissions import SAFE_METHODS, AllowAny, IsAuthenticated
|
|
37
|
+
from rest_framework.request import Request
|
|
38
|
+
from rest_framework.response import Response
|
|
39
|
+
from rest_framework.throttling import ScopedRateThrottle
|
|
40
|
+
from rest_framework.views import APIView
|
|
41
|
+
from rest_framework_simplejwt.tokens import RefreshToken
|
|
42
|
+
|
|
43
|
+
from trueppm_api.apps.access.permissions import IsNotTokenAuthenticated
|
|
44
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
45
|
+
from trueppm_api.apps.sso import services
|
|
46
|
+
from trueppm_api.apps.sso.models import SsoProviderPolicy
|
|
47
|
+
from trueppm_api.apps.sso.serializers import (
|
|
48
|
+
SsoDiscoverResponseSerializer,
|
|
49
|
+
SsoProviderReadSerializer,
|
|
50
|
+
SsoProviderWriteSerializer,
|
|
51
|
+
SsoRedirectUriResponseSerializer,
|
|
52
|
+
SsoTestConnectionResponseSerializer,
|
|
53
|
+
)
|
|
54
|
+
from trueppm_api.apps.workspace.models import AuditEventType, Workspace
|
|
55
|
+
from trueppm_api.apps.workspace.permissions import (
|
|
56
|
+
IsWorkspaceAdminStrict,
|
|
57
|
+
_workspace_membership_role,
|
|
58
|
+
)
|
|
59
|
+
from trueppm_api.core.auth_views import (
|
|
60
|
+
_apply_remember,
|
|
61
|
+
_cookie_seconds,
|
|
62
|
+
_set_refresh_cookie,
|
|
63
|
+
emit_login_success,
|
|
64
|
+
)
|
|
65
|
+
from trueppm_api.core.constant_time import constant_time_equal
|
|
66
|
+
from trueppm_api.core.throttling import ProbeExemptUserRateThrottle
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger("trueppm.sso")
|
|
69
|
+
|
|
70
|
+
# Trailing slash matches the route in ``urls.py`` so the IdP returns straight to
|
|
71
|
+
# the view without an APPEND_SLASH redirect hop dropping the query. UNCHANGED for
|
|
72
|
+
# every provider (ADR-0517 §3.5) — OTel redaction + operator allow-lists depend
|
|
73
|
+
# on this exact path.
|
|
74
|
+
_CALLBACK_PATH = "/api/v1/auth/oidc/callback/"
|
|
75
|
+
|
|
76
|
+
# Browser-binding cookie for the OIDC/OAuth ``state``. The server-side single-use
|
|
77
|
+
# state only proves *we* minted the value; it does not prove the *same browser*
|
|
78
|
+
# that began the flow is the one completing it. Without this binding, an attacker
|
|
79
|
+
# who completes login at their own IdP account could hand the resulting
|
|
80
|
+
# ``?state=&code=`` callback URL to a victim and silently sign the victim into the
|
|
81
|
+
# attacker's account (login CSRF / session fixation). The callback therefore also
|
|
82
|
+
# requires the ``state`` query param to equal the value stored in this cookie.
|
|
83
|
+
# ``SameSite=Lax`` (not Strict) is required: the callback arrives as a top-level
|
|
84
|
+
# GET navigation from the IdP origin, which Strict would strip. Path-scoped to the
|
|
85
|
+
# OIDC routes so it is never attached to ordinary API calls.
|
|
86
|
+
_STATE_COOKIE_NAME = "trueppm_oidc_state"
|
|
87
|
+
_STATE_COOKIE_PATH = "/api/v1/auth/oidc/"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _set_state_cookie(response: HttpResponse, state: str) -> None:
|
|
91
|
+
response.set_cookie(
|
|
92
|
+
key=_STATE_COOKIE_NAME,
|
|
93
|
+
value=state,
|
|
94
|
+
max_age=int(getattr(settings, "OIDC_STATE_TTL_SECONDS", 300)),
|
|
95
|
+
httponly=True,
|
|
96
|
+
secure=settings.AUTH_REFRESH_COOKIE_SECURE,
|
|
97
|
+
samesite="Lax",
|
|
98
|
+
path=_STATE_COOKIE_PATH,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _clear_state_cookie(response: HttpResponse) -> None:
|
|
103
|
+
response.delete_cookie(key=_STATE_COOKIE_NAME, path=_STATE_COOKIE_PATH, samesite="Lax")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _derive_redirect_uri(request: Request) -> str:
|
|
107
|
+
"""The redirect_uri sent to the IdP and shown read-only in the admin page.
|
|
108
|
+
|
|
109
|
+
Prefers the explicit ``TRUEPPM_PUBLIC_API_BASE_URL`` (so the value the operator
|
|
110
|
+
allow-lists is deterministic behind a proxy); falls back to the request's
|
|
111
|
+
absolute URI for zero-config single-origin dev. The exact string is stored in
|
|
112
|
+
the login state and replayed at the token endpoint, so it always matches. It is
|
|
113
|
+
the **same path for every provider** (ADR-0517 §3.5).
|
|
114
|
+
"""
|
|
115
|
+
base = (getattr(settings, "TRUEPPM_PUBLIC_API_BASE_URL", "") or "").rstrip("/")
|
|
116
|
+
if base:
|
|
117
|
+
return f"{base}{_CALLBACK_PATH}"
|
|
118
|
+
return request.build_absolute_uri(_CALLBACK_PATH)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _spa_completion_url(error: str | None = None) -> str:
|
|
122
|
+
"""The SPA route the callback redirects to (optionally with a non-sensitive code)."""
|
|
123
|
+
base = (getattr(settings, "FRONTEND_BASE_URL", "") or "").rstrip("/")
|
|
124
|
+
url = f"{base}/auth/sso/complete"
|
|
125
|
+
if error:
|
|
126
|
+
url = f"{url}?error={error}"
|
|
127
|
+
return url
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class OIDCDiscoverView(APIView):
|
|
131
|
+
"""``GET /auth/oidc/discover?email=`` — which enabled providers this domain uses.
|
|
132
|
+
|
|
133
|
+
Domain-level only: it never touches the user table and never reveals whether
|
|
134
|
+
an account exists (no enumeration leak). With an ``email`` it returns the
|
|
135
|
+
enabled providers whose domain allow-list admits that address; without one it
|
|
136
|
+
returns every enabled provider (for a login screen that renders a button per
|
|
137
|
+
provider). Always 200.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
permission_classes = [AllowAny]
|
|
141
|
+
authentication_classes: list[Any] = []
|
|
142
|
+
throttle_classes = [ScopedRateThrottle]
|
|
143
|
+
throttle_scope = "oidc_discover"
|
|
144
|
+
|
|
145
|
+
@extend_schema(
|
|
146
|
+
summary="Discover which SSO providers are available (optionally for an email domain)",
|
|
147
|
+
parameters=[OpenApiParameter("email", str, OpenApiParameter.QUERY, required=False)],
|
|
148
|
+
responses={200: SsoDiscoverResponseSerializer},
|
|
149
|
+
auth=[],
|
|
150
|
+
tags=["auth"],
|
|
151
|
+
)
|
|
152
|
+
def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
153
|
+
email = (request.query_params.get("email") or "").strip()
|
|
154
|
+
if email:
|
|
155
|
+
matched = services.domain_matches_any_enabled(email)
|
|
156
|
+
contexts = [matched] if matched is not None else []
|
|
157
|
+
else:
|
|
158
|
+
contexts = services.get_enabled_providers()
|
|
159
|
+
providers = [{"slug": c.slug, "display_name": c.display_name} for c in contexts]
|
|
160
|
+
payload = {"provider_present": bool(providers), "providers": providers}
|
|
161
|
+
return Response(SsoDiscoverResponseSerializer(payload).data)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class OIDCLoginView(APIView):
|
|
165
|
+
"""``GET /auth/oidc/login?provider=<slug>`` — start the flow for one provider."""
|
|
166
|
+
|
|
167
|
+
permission_classes = [AllowAny]
|
|
168
|
+
authentication_classes: list[Any] = []
|
|
169
|
+
throttle_classes = [ScopedRateThrottle]
|
|
170
|
+
throttle_scope = "oidc_login"
|
|
171
|
+
|
|
172
|
+
@extend_schema(
|
|
173
|
+
summary="Begin SSO login for a provider (redirects to the IdP)",
|
|
174
|
+
parameters=[OpenApiParameter("provider", str, OpenApiParameter.QUERY, required=False)],
|
|
175
|
+
responses={
|
|
176
|
+
302: OpenApiResponse(description="Redirect to the IdP authorization endpoint."),
|
|
177
|
+
},
|
|
178
|
+
auth=[],
|
|
179
|
+
tags=["auth"],
|
|
180
|
+
)
|
|
181
|
+
def get(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
182
|
+
ctx = self._resolve_provider(request)
|
|
183
|
+
if ctx is None:
|
|
184
|
+
return HttpResponseRedirect(_spa_completion_url(error="sso_not_configured"))
|
|
185
|
+
try:
|
|
186
|
+
result = services.start_login(ctx, redirect_uri=_derive_redirect_uri(request))
|
|
187
|
+
except services.OIDCError as exc:
|
|
188
|
+
return HttpResponseRedirect(_spa_completion_url(error=exc.code))
|
|
189
|
+
# Bind the state to this browser (login-CSRF / session-fixation defense).
|
|
190
|
+
response = HttpResponseRedirect(result.authorization_url)
|
|
191
|
+
_set_state_cookie(response, result.state)
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
def _resolve_provider(self, request: Request) -> services.ProviderContext | None:
|
|
195
|
+
"""Resolve the provider from ``?provider=<slug>``, or the sole enabled one.
|
|
196
|
+
|
|
197
|
+
Explicit slug wins. With no slug, fall back to the single enabled provider
|
|
198
|
+
(the common single-IdP install) — otherwise the caller must disambiguate.
|
|
199
|
+
"""
|
|
200
|
+
slug = (request.query_params.get("provider") or "").strip()
|
|
201
|
+
if slug:
|
|
202
|
+
return services.get_provider_for_slug(slug)
|
|
203
|
+
enabled = services.get_enabled_providers()
|
|
204
|
+
return enabled[0] if len(enabled) == 1 else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class OIDCCallbackView(APIView):
|
|
208
|
+
"""``GET /auth/oidc/callback?code=&state=`` — complete the flow, mint the session.
|
|
209
|
+
|
|
210
|
+
On success: validate state (single-use, browser-bound) → resolve the provider
|
|
211
|
+
from the ``slug`` stored in the state → exchange code → (OIDC) validate ID
|
|
212
|
+
token / (GitHub) fetch userinfo → resolve/link/create the user → **refuse a
|
|
213
|
+
deactivated account** → set the httpOnly refresh cookie → 302 to the SPA
|
|
214
|
+
completion route (no token in the URL). On any failure: 302 to the SPA
|
|
215
|
+
completion route with a non-sensitive ``error`` code.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
permission_classes = [AllowAny]
|
|
219
|
+
authentication_classes: list[Any] = []
|
|
220
|
+
throttle_classes = [ScopedRateThrottle]
|
|
221
|
+
throttle_scope = "oidc_callback"
|
|
222
|
+
|
|
223
|
+
def _redirect(self, *, error: str | None = None) -> HttpResponse:
|
|
224
|
+
"""Redirect to the SPA completion route, always clearing the state cookie."""
|
|
225
|
+
response = HttpResponseRedirect(_spa_completion_url(error=error))
|
|
226
|
+
_clear_state_cookie(response)
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
@extend_schema(
|
|
230
|
+
summary="SSO callback (sets the refresh cookie, redirects to the SPA)",
|
|
231
|
+
parameters=[
|
|
232
|
+
OpenApiParameter("code", str, OpenApiParameter.QUERY, required=False),
|
|
233
|
+
OpenApiParameter("state", str, OpenApiParameter.QUERY, required=False),
|
|
234
|
+
OpenApiParameter("error", str, OpenApiParameter.QUERY, required=False),
|
|
235
|
+
],
|
|
236
|
+
responses={302: OpenApiResponse(description="Redirect to the SPA completion route.")},
|
|
237
|
+
auth=[],
|
|
238
|
+
tags=["auth"],
|
|
239
|
+
)
|
|
240
|
+
def get(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
241
|
+
# The IdP can redirect back with an error (e.g. user denied consent).
|
|
242
|
+
idp_error = request.query_params.get("error")
|
|
243
|
+
if idp_error:
|
|
244
|
+
return self._redirect(error="access_denied")
|
|
245
|
+
|
|
246
|
+
# SSO entirely off → fail closed before touching state.
|
|
247
|
+
if not services.get_enabled_providers():
|
|
248
|
+
return self._redirect(error="sso_not_configured")
|
|
249
|
+
|
|
250
|
+
code = request.query_params.get("code") or ""
|
|
251
|
+
state = request.query_params.get("state") or ""
|
|
252
|
+
if not code or not state:
|
|
253
|
+
return self._redirect(error="invalid_request")
|
|
254
|
+
|
|
255
|
+
# Browser-binding check: the state query param must match the cookie set at
|
|
256
|
+
# /login for this browser. consume_state proves we issued the state; the
|
|
257
|
+
# cookie proves the same browser began the flow (login-CSRF defense).
|
|
258
|
+
# Constant-time compare avoids leaking a match via timing. Both sides are
|
|
259
|
+
# caller-controlled here — a query parameter and a cookie — so the compare
|
|
260
|
+
# goes through core.constant_time, which compares bytes and therefore
|
|
261
|
+
# cannot raise on non-ASCII the way compare_digest does on str (#2929).
|
|
262
|
+
cookie_state = request.COOKIES.get(_STATE_COOKIE_NAME) or ""
|
|
263
|
+
if not cookie_state or not constant_time_equal(cookie_state, state):
|
|
264
|
+
return self._redirect(error="invalid_state")
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
stored = services.consume_state(state)
|
|
268
|
+
ctx = services.get_provider_for_slug(str(stored.get("slug") or ""))
|
|
269
|
+
if ctx is None:
|
|
270
|
+
return self._redirect(error="sso_not_configured")
|
|
271
|
+
claims = self._complete(ctx, code=code, stored=stored)
|
|
272
|
+
user, _created = services.resolve_user(ctx, claims)
|
|
273
|
+
except services.OIDCError as exc:
|
|
274
|
+
return self._redirect(error=exc.code)
|
|
275
|
+
|
|
276
|
+
# Mint the existing cookie-JWT session — no new token surface (ADR-0517 §3.3).
|
|
277
|
+
# SSO logins are always session-scoped (#2246): there is no "remember me"
|
|
278
|
+
# checkbox in an IdP redirect, and the operator's IdP owns device trust, so
|
|
279
|
+
# defaulting an unattended redirect to a 30-day persistent cookie would be the
|
|
280
|
+
# wrong safe default. A session cookie (dies on browser close) + short exp.
|
|
281
|
+
refresh = RefreshToken.for_user(user)
|
|
282
|
+
_apply_remember(refresh, remember=False)
|
|
283
|
+
response = self._redirect()
|
|
284
|
+
_set_refresh_cookie(
|
|
285
|
+
response, # type: ignore[arg-type] # HttpResponseRedirect; set_cookie is shared
|
|
286
|
+
str(refresh),
|
|
287
|
+
persistent_seconds=_cookie_seconds(remember=False), # None → session cookie
|
|
288
|
+
)
|
|
289
|
+
# AFTER the cookie is set, not after resolve_user (#3552, ADR-1120): the session
|
|
290
|
+
# is what succeeded. Emitted on ``trueppm.auth`` — not this module's
|
|
291
|
+
# ``trueppm.sso`` logger — so an operator correlating "who got in" against
|
|
292
|
+
# ``auth.login_failed`` reads one channel and does not have to know which door
|
|
293
|
+
# the user came through. ``remember`` is always False here: an IdP redirect
|
|
294
|
+
# carries no such choice, which is the same reason ``_apply_remember`` is called
|
|
295
|
+
# with False above.
|
|
296
|
+
emit_login_success(request, user=user, method=f"sso:{ctx.slug}", remember=False)
|
|
297
|
+
return response
|
|
298
|
+
|
|
299
|
+
def _complete(
|
|
300
|
+
self, ctx: services.ProviderContext, *, code: str, stored: dict[str, Any]
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""Run the provider-specific exchange and return normalized claims."""
|
|
303
|
+
redirect_uri = stored["redirect_uri"]
|
|
304
|
+
if ctx.is_github:
|
|
305
|
+
access_token = services.exchange_github_code(ctx, code=code, redirect_uri=redirect_uri)
|
|
306
|
+
return services.fetch_github_identity(ctx, access_token)
|
|
307
|
+
doc = services.get_discovery_document(ctx.issuer)
|
|
308
|
+
tokens = services.exchange_code(
|
|
309
|
+
ctx, doc, code=code, redirect_uri=redirect_uri, verifier=stored["verifier"]
|
|
310
|
+
)
|
|
311
|
+
return services.validate_id_token(
|
|
312
|
+
ctx, doc, tokens["id_token"], expected_nonce=stored["nonce"]
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Admin config — collection under /workspace/sso/providers/ (ADR-0517 §3.4)
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
# Session/JWT only, on all three admin views (#3551).
|
|
321
|
+
#
|
|
322
|
+
# ``IsNotTokenAuthenticated`` (#2878) refuses any caller whose credential is an
|
|
323
|
+
# ``ApiToken``. It was written for the credential-management surface, but this
|
|
324
|
+
# surface is the same class of hazard one step removed: an admin's leaked
|
|
325
|
+
# ``legacy:full`` personal access token could rewrite the *join policy* — set
|
|
326
|
+
# ``auto_create_members`` with ``default_role`` ADMIN, add an attacker-controlled
|
|
327
|
+
# domain to ``allowed_email_domains``, rotate the IdP client secret, or delete a
|
|
328
|
+
# provider with ``?confirm_lockout=true``. One SSO login at the widened domain then
|
|
329
|
+
# mints a real, persistent ADMIN session, so revoking the token afterwards contains
|
|
330
|
+
# nothing: the durable grant it created outlives it. That makes the containment
|
|
331
|
+
# promise in ``features/personal-access-tokens.md#revoking-a-token`` false for an
|
|
332
|
+
# admin's token unless this surface is closed too.
|
|
333
|
+
#
|
|
334
|
+
# Declared **before** ``IsWorkspaceAdminStrict`` so the refusal is deterministic:
|
|
335
|
+
# DRF stops at the first class that returns False, and a token caller must always
|
|
336
|
+
# get the token refusal (with its ``capability_scope`` envelope) rather than an
|
|
337
|
+
# answer that depends on whether the token's owner happens to hold ADMIN.
|
|
338
|
+
#
|
|
339
|
+
# It is listed statically on every view rather than contributed by
|
|
340
|
+
# ``get_permissions``, because the schema generator
|
|
341
|
+
# (``core/openapi.py::_token_callers_refused``) and the route-table tripwire
|
|
342
|
+
# (``tests/apps/access/test_route_table_invariants.py``) both read
|
|
343
|
+
# ``permission_classes`` — a runtime-only guard is invisible to both.
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _refuse_default_role_at_or_above_actor(
|
|
347
|
+
request: Request, validated_data: dict[str, Any]
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Refuse writing a ``default_role`` equal to or higher than the actor's own (#3626).
|
|
350
|
+
|
|
351
|
+
Mirrors the actor-ceiling ``>=`` comparison
|
|
352
|
+
``workspace.views._apply_member_role_change`` and ``WorkspaceInviteListView.post``
|
|
353
|
+
apply to member-role grants and invites, so the same peer/self-duplication
|
|
354
|
+
invariant holds on the third path that can mint a role: SSO auto-provisioning.
|
|
355
|
+
Without this, a workspace ADMIN could set ``default_role=ADMIN`` (already the
|
|
356
|
+
ceiling ``_ALLOWED_DEFAULT_ROLES`` permits) and self-service-provision a peer
|
|
357
|
+
ADMIN account through a second IdP identity — a grant the member-PATCH and
|
|
358
|
+
invite paths both refuse outright.
|
|
359
|
+
|
|
360
|
+
Only checked when the client actually sent ``default_role`` — a partial
|
|
361
|
+
``PUT`` that touches unrelated fields must not fail on a value it never
|
|
362
|
+
submitted, matching every other field-conditional branch in
|
|
363
|
+
``SsoProviderDetailView.put``. ``resolve_user``'s auto-create branch is
|
|
364
|
+
intentionally left alone: the ceiling belongs at this write gate on the
|
|
365
|
+
policy, not at sign-in time, so an existing over-privileged policy keeps
|
|
366
|
+
provisioning at its stored role until an OWNER edits it down.
|
|
367
|
+
"""
|
|
368
|
+
if "default_role" not in validated_data:
|
|
369
|
+
return
|
|
370
|
+
actor_role = _workspace_membership_role(request)
|
|
371
|
+
if actor_role is not None and validated_data["default_role"] >= actor_role:
|
|
372
|
+
raise PermissionDenied(
|
|
373
|
+
"You cannot set default_role to a role equal to or higher than your own."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _policy_or_none(slug: str) -> SsoProviderPolicy | None:
|
|
378
|
+
return (
|
|
379
|
+
SsoProviderPolicy.objects.select_related("social_app")
|
|
380
|
+
.filter(workspace=Workspace.load(), slug=slug)
|
|
381
|
+
.first()
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# Query flag an admin must send to remove a provider that is somebody's only way in
|
|
386
|
+
# (#2874). A query parameter rather than a body field because DELETE bodies are not
|
|
387
|
+
# reliably forwarded by proxies and are not part of this collection's request shape.
|
|
388
|
+
_CONFIRM_LOCKOUT_PARAM = "confirm_lockout"
|
|
389
|
+
|
|
390
|
+
# The one constraint POST is allowed to translate into a 409 (see ``post``).
|
|
391
|
+
_SLUG_UNIQUE_CONSTRAINT = "uniq_sso_policy_workspace_slug"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class _SsoProviderWriteThrottle(ScopedRateThrottle):
|
|
395
|
+
"""The ``sso_provider_write`` scope, applied to mutating methods only (#3552).
|
|
396
|
+
|
|
397
|
+
``ScopedRateThrottle`` normally caps every method on the view. That is wrong here:
|
|
398
|
+
the collection GET backs the admin page — which the SPA re-reads on navigation and
|
|
399
|
+
after each save — while it is the *writes* that need bounding, because each accepted
|
|
400
|
+
write appends a row to an audit table with no OSS retention (ADR-1120). Capping reads
|
|
401
|
+
at a write rate would break the page to fix the disk.
|
|
402
|
+
|
|
403
|
+
**The scope must be declared on the VIEW, not here.** ``ScopedRateThrottle`` reassigns
|
|
404
|
+
``self.scope = getattr(view, self.scope_attr, None)`` on every ``allow_request`` and
|
|
405
|
+
returns ``True`` when that is falsy — so a ``scope`` class attribute on the throttle is
|
|
406
|
+
silently overwritten and the throttle becomes a no-op. Both views therefore set
|
|
407
|
+
``throttle_scope``, exactly as every other scoped view in this module does.
|
|
408
|
+
|
|
409
|
+
Both views also keep ``ProbeExemptUserRateThrottle`` in their ``throttle_classes``.
|
|
410
|
+
Declaring ``throttle_classes`` *replaces* ``DEFAULT_THROTTLE_CLASSES`` rather than
|
|
411
|
+
adding to it, so listing this class alone would strip the global 1000/min ``user``
|
|
412
|
+
ceiling from the reads this class deliberately exempts — leaving them with no bound at
|
|
413
|
+
all, which is worse than before the throttle was added.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
def allow_request(self, request: Request, view: APIView) -> bool:
|
|
417
|
+
# Django uppercases REQUEST_METHOD, so a lowercase verb still arrives as e.g.
|
|
418
|
+
# "POST"; anything not in SAFE_METHODS falls through to the throttled branch, so
|
|
419
|
+
# an unrecognized method fails in the safe direction.
|
|
420
|
+
if request.method in SAFE_METHODS:
|
|
421
|
+
return True
|
|
422
|
+
return bool(super().allow_request(request, view))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# Provider fields whose change is recorded in an ``sso_provider_updated`` diff (#3552,
|
|
426
|
+
# ADR-1120). This is the **writable surface of the serializer**, minus the secret — not
|
|
427
|
+
# ``SsoProviderPolicy._meta.fields``: ``server_url``, ``client_id`` and ``display_name``
|
|
428
|
+
# live on the linked ``SocialApp``, so a model-derived list would silently exempt the
|
|
429
|
+
# three fields that most change who can sign in.
|
|
430
|
+
#
|
|
431
|
+
# ``client_id`` is here because it determines which OAuth client the install presents
|
|
432
|
+
# itself as; paired with a secret rotation it is a complete credential swap, and without
|
|
433
|
+
# it the log would show "a secret was rotated" and never that the client identity moved
|
|
434
|
+
# underneath it. It is not secret material — the read serializer already exposes it.
|
|
435
|
+
#
|
|
436
|
+
# ``allow_password_signin`` is deliberately absent: the OSS write serializer rejects the
|
|
437
|
+
# field outright, so in this edition it cannot change. Whoever makes it writable in
|
|
438
|
+
# Enterprise must add it here by hand — the test below enumerates the *serializer's*
|
|
439
|
+
# writable fields, and that field is already declared, so nothing will fail on its own.
|
|
440
|
+
_AUDITED_PROVIDER_FIELDS = (
|
|
441
|
+
"allowed_email_domains",
|
|
442
|
+
"auto_create_members",
|
|
443
|
+
"client_id",
|
|
444
|
+
"default_role",
|
|
445
|
+
"display_name",
|
|
446
|
+
"enabled",
|
|
447
|
+
"github_org",
|
|
448
|
+
"server_url",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Maximum entries kept per list value in an audit diff. ``allowed_email_domains`` has no
|
|
452
|
+
# length cap on either the serializer or the ArrayField, and a diff stores it twice, so an
|
|
453
|
+
# uncapped row is arbitrarily large in a table nothing prunes.
|
|
454
|
+
_MAX_AUDITED_LIST = 25
|
|
455
|
+
|
|
456
|
+
# Maximum characters kept per string value in an audit diff. Comfortably above any real
|
|
457
|
+
# issuer URL, display name, or client id, and far below what would make one row a
|
|
458
|
+
# storage problem.
|
|
459
|
+
_MAX_AUDITED_STRING = 1024
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _cap(value: Any) -> Any:
|
|
463
|
+
"""Bound one audit-diff value, marking it when something was cut.
|
|
464
|
+
|
|
465
|
+
Both a list's *length* and a string's *length* are bounded, because
|
|
466
|
+
``record_audit_event`` truncates ``target_label`` to 512 characters but writes
|
|
467
|
+
``metadata`` verbatim — so nothing downstream limits what lands here.
|
|
468
|
+
|
|
469
|
+
The string cap is not theoretical. ``server_url`` is a ``CharField`` with no
|
|
470
|
+
``max_length``, and its validator requires only an http(s) scheme, a netloc, and no
|
|
471
|
+
query or fragment — the *path* is unbounded. It is stored in ``SocialApp.settings``,
|
|
472
|
+
a JSONField with no ceiling, and an update row would hold it twice (``from`` and
|
|
473
|
+
``to``). Without this an Admin could inflate the unpruned audit table at will.
|
|
474
|
+
"""
|
|
475
|
+
if isinstance(value, list):
|
|
476
|
+
capped = [_cap(item) for item in value[:_MAX_AUDITED_LIST]]
|
|
477
|
+
if len(value) > _MAX_AUDITED_LIST:
|
|
478
|
+
return {"items": capped, "total": len(value), "truncated": True}
|
|
479
|
+
return capped
|
|
480
|
+
if isinstance(value, str) and len(value) > _MAX_AUDITED_STRING:
|
|
481
|
+
return {
|
|
482
|
+
"value": value[:_MAX_AUDITED_STRING],
|
|
483
|
+
"total": len(value),
|
|
484
|
+
"truncated": True,
|
|
485
|
+
}
|
|
486
|
+
return value
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _actor_kind(request: Request) -> str:
|
|
490
|
+
"""Whether this write came from an interactive session or a Personal Access Token.
|
|
491
|
+
|
|
492
|
+
These views set ``permission_classes`` but not ``authentication_classes``, so they
|
|
493
|
+
inherit ``OwnerScopedApiTokenAuthentication``: an Admin's ``legacy:full`` token
|
|
494
|
+
authenticates here and passes ``IsWorkspaceAdminStrict`` (all three routes are listed
|
|
495
|
+
in ``tests/apps/access/token_write_surface.txt``, and none of them writes an
|
|
496
|
+
``AgentAction`` row). Without this the audit row attributes a machine-driven
|
|
497
|
+
credential-path change to the token's human owner, indistinguishable from that human
|
|
498
|
+
sitting at a browser — on the one row that exists to answer *who did this*.
|
|
499
|
+
|
|
500
|
+
``request.auth`` holds an ``ApiToken`` for token auth and a simplejwt ``Token`` for an
|
|
501
|
+
interactive session, so the isinstance check is the discriminator rather than a
|
|
502
|
+
None-test.
|
|
503
|
+
"""
|
|
504
|
+
from trueppm_api.apps.projects.models import ApiToken
|
|
505
|
+
|
|
506
|
+
return "token" if isinstance(request.auth, ApiToken) else "session"
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _provider_snapshot(policy: SsoProviderPolicy) -> dict[str, Any]:
|
|
510
|
+
"""Current values of the audited fields, reading through to the linked SocialApp."""
|
|
511
|
+
app = policy.social_app
|
|
512
|
+
return {
|
|
513
|
+
"allowed_email_domains": list(policy.allowed_email_domains),
|
|
514
|
+
"auto_create_members": policy.auto_create_members,
|
|
515
|
+
"client_id": app.client_id,
|
|
516
|
+
"default_role": int(policy.default_role),
|
|
517
|
+
"display_name": app.name,
|
|
518
|
+
"enabled": policy.enabled,
|
|
519
|
+
"github_org": policy.github_org,
|
|
520
|
+
"server_url": str(app.settings.get("server_url", "")),
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _record_provider_audit(
|
|
525
|
+
request: Request,
|
|
526
|
+
*,
|
|
527
|
+
event_type: str,
|
|
528
|
+
policy: SsoProviderPolicy,
|
|
529
|
+
slug: str,
|
|
530
|
+
metadata: dict[str, Any],
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Write one SSO provider audit row (#3552, ADR-1120).
|
|
533
|
+
|
|
534
|
+
Called only on paths that have already succeeded. That is the whole reason this
|
|
535
|
+
feature never meets the ``set_rollback`` trap of ADR-0902: under ``ATOMIC_REQUESTS``
|
|
536
|
+
DRF's exception handler rolls back for *every* ``APIException``, so a row written on a
|
|
537
|
+
400/403/409 path is issued and silently discarded. Here a refusal simply writes
|
|
538
|
+
nothing, which is also the correct record — nothing changed.
|
|
539
|
+
|
|
540
|
+
``metadata`` is enumerated by each caller; the secret never appears in it in any form,
|
|
541
|
+
including a length, hash, or prefix.
|
|
542
|
+
"""
|
|
543
|
+
# ``record_audit_event`` is imported at call time, matching ``sso/services.py`` and
|
|
544
|
+
# ``projects/views.py::_record_project_audit_event``: the sso→workspace dependency is
|
|
545
|
+
# one-way and stays that way at module level. (``AuditEventType`` is a plain enum on
|
|
546
|
+
# ``workspace.models``, which this module already imports for ``Workspace``, so it
|
|
547
|
+
# needs no deferral.)
|
|
548
|
+
from trueppm_api.apps.workspace.services import record_audit_event
|
|
549
|
+
|
|
550
|
+
record_audit_event(
|
|
551
|
+
event_type=event_type,
|
|
552
|
+
actor=request.user,
|
|
553
|
+
target_type="sso_provider",
|
|
554
|
+
target_id=policy.pk,
|
|
555
|
+
target_label=slug,
|
|
556
|
+
metadata={**metadata, "actor_kind": _actor_kind(request)},
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _is_duplicate_slug(exc: IntegrityError) -> bool:
|
|
561
|
+
"""Whether ``exc`` is the ``(workspace, slug)`` unique-constraint collision.
|
|
562
|
+
|
|
563
|
+
psycopg exposes the violated constraint's name on the wrapped driver error, so
|
|
564
|
+
match on that rather than assuming every integrity failure in ``create()`` is a
|
|
565
|
+
duplicate provider. The message fallback covers backends (and re-raised wrappers)
|
|
566
|
+
that carry no ``diag``; PostgreSQL always names the constraint in the text.
|
|
567
|
+
"""
|
|
568
|
+
constraint = getattr(getattr(exc.__cause__, "diag", None), "constraint_name", None)
|
|
569
|
+
if constraint:
|
|
570
|
+
return bool(constraint == _SLUG_UNIQUE_CONSTRAINT)
|
|
571
|
+
return _SLUG_UNIQUE_CONSTRAINT in str(exc)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class SsoRedirectUriView(APIView):
|
|
575
|
+
"""``GET /workspace/sso/redirect-uri/`` — the callback URL, with no providers required.
|
|
576
|
+
|
|
577
|
+
The redirect URI is derived from server config (``_derive_redirect_uri``), not
|
|
578
|
+
from any saved provider row — it is identical for every provider and needs
|
|
579
|
+
none of them to exist. ``SsoProviderReadSerializer.redirect_uri`` exposes the
|
|
580
|
+
same value, but only per-provider, so it is unreachable while the provider
|
|
581
|
+
list is empty. That is a genuine chicken-and-egg gap on the very first
|
|
582
|
+
provider an admin adds (#3690): registering an OAuth application with most
|
|
583
|
+
IdPs (GitLab included) requires this URL up front, and the IdP only issues a
|
|
584
|
+
client id/secret once that application exists — so the admin needs the value
|
|
585
|
+
*before* they have anything to save here. Same admin gate as the collection
|
|
586
|
+
view: this reveals the workspace's public API base URL, which is not secret,
|
|
587
|
+
but keeping the gate uniform avoids relying on that judgment call twice.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
permission_classes = [IsAuthenticated, IsNotTokenAuthenticated, IsWorkspaceAdminStrict]
|
|
591
|
+
|
|
592
|
+
@extend_schema(
|
|
593
|
+
summary="Get the SSO redirect URI (identical for every provider)",
|
|
594
|
+
responses={200: SsoRedirectUriResponseSerializer},
|
|
595
|
+
tags=["workspace"],
|
|
596
|
+
)
|
|
597
|
+
def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
598
|
+
return Response({"redirect_uri": _derive_redirect_uri(request)})
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class SsoProviderCollectionView(IdempotencyMixin, APIView):
|
|
602
|
+
"""``/workspace/sso/providers/`` — list (GET) and create (POST) providers.
|
|
603
|
+
|
|
604
|
+
``IsWorkspaceAdminStrict`` on every method: even a GET discloses IdP topology
|
|
605
|
+
(issuers, client ids, allowed domains), so reads are ADMIN-gated exactly like
|
|
606
|
+
writes. Session/JWT only on top of that (``IsNotTokenAuthenticated``): an API token
|
|
607
|
+
cannot read or change SSO provider configuration at all.
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
permission_classes = [IsAuthenticated, IsNotTokenAuthenticated, IsWorkspaceAdminStrict]
|
|
611
|
+
# Writes bounded at 20/min (#3552). ``throttle_scope`` MUST be on the view:
|
|
612
|
+
# ScopedRateThrottle reads it off the view and no-ops without it. And
|
|
613
|
+
# ``ProbeExemptUserRateThrottle`` is kept because declaring ``throttle_classes``
|
|
614
|
+
# REPLACES DEFAULT_THROTTLE_CLASSES — listing the write throttle alone would strip
|
|
615
|
+
# the global 1000/min ceiling from the reads it deliberately exempts.
|
|
616
|
+
throttle_classes = [ProbeExemptUserRateThrottle, _SsoProviderWriteThrottle]
|
|
617
|
+
throttle_scope = "sso_provider_write"
|
|
618
|
+
# Exempt from the generic Idempotency-Key path (ADR-0170): create keys on the
|
|
619
|
+
# unique (workspace, slug) constraint, so a replayed POST 409s. That was true of
|
|
620
|
+
# the constraint but not of the response until #2875 — nothing mapped the
|
|
621
|
+
# resulting IntegrityError, so it surfaced as a 500. ``post`` now translates it.
|
|
622
|
+
idempotency_exempt = True
|
|
623
|
+
|
|
624
|
+
def _read(self, policy: SsoProviderPolicy, request: Request) -> dict[str, Any]:
|
|
625
|
+
return SsoProviderReadSerializer(
|
|
626
|
+
policy,
|
|
627
|
+
context={
|
|
628
|
+
"redirect_uri": _derive_redirect_uri(request),
|
|
629
|
+
"removal_impact": services.removal_impact([policy.slug]),
|
|
630
|
+
},
|
|
631
|
+
).data
|
|
632
|
+
|
|
633
|
+
@extend_schema(
|
|
634
|
+
summary="List configured SSO providers",
|
|
635
|
+
responses={200: SsoProviderReadSerializer(many=True)},
|
|
636
|
+
tags=["workspace"],
|
|
637
|
+
)
|
|
638
|
+
def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
639
|
+
policies = list(
|
|
640
|
+
SsoProviderPolicy.objects.select_related("social_app").filter(
|
|
641
|
+
workspace=Workspace.load()
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
# One impact map for the whole collection: ``removal_impact`` is two queries
|
|
645
|
+
# for any number of slugs, so building it here keeps the list endpoint flat
|
|
646
|
+
# instead of paying two queries per row.
|
|
647
|
+
context = {
|
|
648
|
+
"redirect_uri": _derive_redirect_uri(request),
|
|
649
|
+
"removal_impact": services.removal_impact([p.slug for p in policies]),
|
|
650
|
+
}
|
|
651
|
+
return Response([SsoProviderReadSerializer(p, context=context).data for p in policies])
|
|
652
|
+
|
|
653
|
+
@extend_schema(
|
|
654
|
+
summary="Add an SSO provider (secret write-only; sending it stores it)",
|
|
655
|
+
request=SsoProviderWriteSerializer,
|
|
656
|
+
responses={
|
|
657
|
+
201: SsoProviderReadSerializer,
|
|
658
|
+
409: OpenApiResponse(description="A provider of this type is already configured."),
|
|
659
|
+
},
|
|
660
|
+
tags=["workspace"],
|
|
661
|
+
)
|
|
662
|
+
def post(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
663
|
+
serializer = SsoProviderWriteSerializer(data=request.data)
|
|
664
|
+
serializer.is_valid(raise_exception=True)
|
|
665
|
+
_refuse_default_role_at_or_above_actor(request, serializer.validated_data)
|
|
666
|
+
try:
|
|
667
|
+
policy = serializer.save()
|
|
668
|
+
except IntegrityError as exc:
|
|
669
|
+
# The unique (workspace, slug) constraint is the collision key — this
|
|
670
|
+
# class's own comment claimed "a replayed POST 409s naturally", but
|
|
671
|
+
# nothing maps IntegrityError, so it escaped as a 500 (#2875). Caught
|
|
672
|
+
# here rather than pre-checked with an ``exists()`` because the
|
|
673
|
+
# constraint is the only race-free arbiter, matching the established
|
|
674
|
+
# pattern at ``projects/services.py`` (#1349).
|
|
675
|
+
#
|
|
676
|
+
# Narrowed to that one constraint on purpose. ``create()`` also writes a
|
|
677
|
+
# SocialApp and an M2M row against Site, so a bare ``except`` would
|
|
678
|
+
# rebrand an unrelated fault (a stale SITE_ID's FK, a constraint added
|
|
679
|
+
# later) as "already configured" — a plausible but wrong diagnosis with
|
|
680
|
+
# no trace left for whoever has to debug it. Anything else is logged and
|
|
681
|
+
# re-raised as the server error it is.
|
|
682
|
+
if not _is_duplicate_slug(exc):
|
|
683
|
+
logger.exception("sso provider create failed on an unexpected constraint")
|
|
684
|
+
raise
|
|
685
|
+
# ``slug`` IS the provider type, so this also reports the real
|
|
686
|
+
# limitation: one provider per registry type per workspace, which means
|
|
687
|
+
# two Keycloak realms cannot both be configured. Say so plainly rather
|
|
688
|
+
# than emitting a bare conflict.
|
|
689
|
+
return Response(
|
|
690
|
+
{
|
|
691
|
+
"detail": (
|
|
692
|
+
"A provider of this type is already configured. Each provider type "
|
|
693
|
+
"can be configured once; edit the existing one instead."
|
|
694
|
+
)
|
|
695
|
+
},
|
|
696
|
+
status=status.HTTP_409_CONFLICT,
|
|
697
|
+
)
|
|
698
|
+
# NOTE: no audit row is written on the 409 branch above, and none may ever be.
|
|
699
|
+
# That branch runs with the connection in an aborted-transaction state after the
|
|
700
|
+
# IntegrityError, so *any* statement issued there raises TransactionManagementError
|
|
701
|
+
# and turns a correct 409 into a 500. It is also the right record: a refused create
|
|
702
|
+
# changed nothing (#3552, ADR-1120).
|
|
703
|
+
_record_provider_audit(
|
|
704
|
+
request,
|
|
705
|
+
event_type=AuditEventType.SSO_PROVIDER_CREATED,
|
|
706
|
+
policy=policy,
|
|
707
|
+
slug=policy.slug,
|
|
708
|
+
metadata={
|
|
709
|
+
"config": {k: _cap(v) for k, v in _provider_snapshot(policy).items()},
|
|
710
|
+
# Whether a client secret was supplied at creation — never the value, its
|
|
711
|
+
# length, or any hash of it.
|
|
712
|
+
"secret_set": policy.secret_set,
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
return Response(self._read(policy, request), status=status.HTTP_201_CREATED)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class SsoProviderDetailView(IdempotencyMixin, APIView):
|
|
719
|
+
"""``/workspace/sso/providers/{slug}/`` — get/update/delete one provider.
|
|
720
|
+
|
|
721
|
+
Session/JWT only, like the collection: an API token cannot read or change SSO
|
|
722
|
+
provider configuration (#3551 — see the section comment above for why).
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
permission_classes = [IsAuthenticated, IsNotTokenAuthenticated, IsWorkspaceAdminStrict]
|
|
726
|
+
# PUT/DELETE bounded at 20/min (#3552); the detail GET keeps only the global
|
|
727
|
+
# 1000/min ceiling. Both lines are load-bearing — see the collection view above and
|
|
728
|
+
# ``_SsoProviderWriteThrottle``.
|
|
729
|
+
throttle_classes = [ProbeExemptUserRateThrottle, _SsoProviderWriteThrottle]
|
|
730
|
+
throttle_scope = "sso_provider_write"
|
|
731
|
+
idempotency_exempt = True
|
|
732
|
+
|
|
733
|
+
def _read(self, policy: SsoProviderPolicy, request: Request) -> dict[str, Any]:
|
|
734
|
+
return SsoProviderReadSerializer(
|
|
735
|
+
policy,
|
|
736
|
+
context={
|
|
737
|
+
"redirect_uri": _derive_redirect_uri(request),
|
|
738
|
+
"removal_impact": services.removal_impact([policy.slug]),
|
|
739
|
+
},
|
|
740
|
+
).data
|
|
741
|
+
|
|
742
|
+
@extend_schema(
|
|
743
|
+
summary="Get one SSO provider configuration",
|
|
744
|
+
responses={200: SsoProviderReadSerializer},
|
|
745
|
+
tags=["workspace"],
|
|
746
|
+
)
|
|
747
|
+
def get(self, request: Request, slug: str, *args: Any, **kwargs: Any) -> Response:
|
|
748
|
+
policy = _policy_or_none(slug)
|
|
749
|
+
if policy is None:
|
|
750
|
+
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
751
|
+
return Response(self._read(policy, request))
|
|
752
|
+
|
|
753
|
+
@extend_schema(
|
|
754
|
+
summary="Update an SSO provider (secret write-only; sending it rotates)",
|
|
755
|
+
request=SsoProviderWriteSerializer,
|
|
756
|
+
responses={200: SsoProviderReadSerializer},
|
|
757
|
+
tags=["workspace"],
|
|
758
|
+
)
|
|
759
|
+
def put(self, request: Request, slug: str, *args: Any, **kwargs: Any) -> Response:
|
|
760
|
+
policy = _policy_or_none(slug)
|
|
761
|
+
if policy is None:
|
|
762
|
+
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
763
|
+
before = _provider_snapshot(policy)
|
|
764
|
+
serializer = SsoProviderWriteSerializer(policy, data=request.data, partial=True)
|
|
765
|
+
serializer.is_valid(raise_exception=True)
|
|
766
|
+
_refuse_default_role_at_or_above_actor(request, serializer.validated_data)
|
|
767
|
+
# Read the rotation flag from ``validated_data`` rather than from the raw body:
|
|
768
|
+
# a blank/absent value never validates (the field is allow_blank=False), so this
|
|
769
|
+
# is true exactly when a real secret was supplied. DRF's ``save()`` passes a
|
|
770
|
+
# *copy* to ``update()``, so the pop inside it leaves ``validated_data`` intact —
|
|
771
|
+
# but reading it before ``save()`` keeps that from being load-bearing.
|
|
772
|
+
rotated = "client_secret" in serializer.validated_data
|
|
773
|
+
policy = serializer.save()
|
|
774
|
+
after = _provider_snapshot(policy)
|
|
775
|
+
|
|
776
|
+
# A change row only when something actually changed. The admin form re-submits
|
|
777
|
+
# every field on every save, and this table has no OSS retention, so a row per
|
|
778
|
+
# form save would accumulate rows that answer no question (#3552, ADR-1120).
|
|
779
|
+
changed = {
|
|
780
|
+
field: {"from": _cap(before[field]), "to": _cap(after[field])}
|
|
781
|
+
for field in _AUDITED_PROVIDER_FIELDS
|
|
782
|
+
if before[field] != after[field]
|
|
783
|
+
}
|
|
784
|
+
if changed:
|
|
785
|
+
_record_provider_audit(
|
|
786
|
+
request,
|
|
787
|
+
event_type=AuditEventType.SSO_PROVIDER_UPDATED,
|
|
788
|
+
policy=policy,
|
|
789
|
+
slug=slug,
|
|
790
|
+
metadata={"changed": changed},
|
|
791
|
+
)
|
|
792
|
+
if rotated:
|
|
793
|
+
# A distinct verb rather than a ``client_secret`` entry in the diff above: the
|
|
794
|
+
# value can never appear, and a diff whose values must always be redacted is a
|
|
795
|
+
# diff-shaped lie the next person extending the field list would have to
|
|
796
|
+
# rediscover. This row says a rotation happened and nothing whatsoever about
|
|
797
|
+
# what it rotated to.
|
|
798
|
+
_record_provider_audit(
|
|
799
|
+
request,
|
|
800
|
+
event_type=AuditEventType.SSO_SECRET_ROTATED,
|
|
801
|
+
policy=policy,
|
|
802
|
+
slug=slug,
|
|
803
|
+
metadata={},
|
|
804
|
+
)
|
|
805
|
+
return Response(self._read(policy, request))
|
|
806
|
+
|
|
807
|
+
@extend_schema(
|
|
808
|
+
summary="Delete an SSO provider configuration",
|
|
809
|
+
parameters=[
|
|
810
|
+
OpenApiParameter(
|
|
811
|
+
_CONFIRM_LOCKOUT_PARAM,
|
|
812
|
+
bool,
|
|
813
|
+
OpenApiParameter.QUERY,
|
|
814
|
+
required=False,
|
|
815
|
+
description=(
|
|
816
|
+
"Required (`true`) when removing this provider would leave one or "
|
|
817
|
+
"more members with no way to sign in. See the 409 body's "
|
|
818
|
+
"`locked_out_account_count`."
|
|
819
|
+
),
|
|
820
|
+
)
|
|
821
|
+
],
|
|
822
|
+
responses={
|
|
823
|
+
204: OpenApiResponse(description="Provider deleted."),
|
|
824
|
+
409: OpenApiResponse(
|
|
825
|
+
description="Removal would lock members out; re-send with the confirm flag."
|
|
826
|
+
),
|
|
827
|
+
},
|
|
828
|
+
tags=["workspace"],
|
|
829
|
+
)
|
|
830
|
+
def delete(self, request: Request, slug: str, *args: Any, **kwargs: Any) -> Response:
|
|
831
|
+
from allauth.socialaccount.models import SocialAccount
|
|
832
|
+
|
|
833
|
+
policy = _policy_or_none(slug)
|
|
834
|
+
if policy is None:
|
|
835
|
+
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
836
|
+
|
|
837
|
+
# Informed-confirmation gate (#2874). Removing a provider is unrecoverable for
|
|
838
|
+
# a member whose only credential is its binding: every JIT-created user has
|
|
839
|
+
# ``set_unusable_password()``, and the password-reset request path gates on
|
|
840
|
+
# ``has_usable_password()``, so they get the anti-enumeration 200 and no email.
|
|
841
|
+
# There is no admin set-password endpoint, so nothing short of re-adding the
|
|
842
|
+
# provider (or a shell) brings them back. The admin may still do it — an
|
|
843
|
+
# operator has to be able to tear down an IdP — but not without being told the
|
|
844
|
+
# number first, which is what the 409 carries.
|
|
845
|
+
impact = services.removal_impact([slug])[slug]
|
|
846
|
+
confirmed = (request.query_params.get(_CONFIRM_LOCKOUT_PARAM) or "").lower() == "true"
|
|
847
|
+
if impact.locked_out_accounts and not confirmed:
|
|
848
|
+
return Response(
|
|
849
|
+
{
|
|
850
|
+
"code": "sso_removal_locks_out_members",
|
|
851
|
+
"detail": (
|
|
852
|
+
f"{impact.locked_out_accounts} member(s) sign in only through this "
|
|
853
|
+
"provider and have no password, so removing it leaves them unable "
|
|
854
|
+
"to sign in at all — they cannot use the password-reset flow either. "
|
|
855
|
+
"They can sign in again if you add this provider back with the same "
|
|
856
|
+
f"issuer. Re-send with ?{_CONFIRM_LOCKOUT_PARAM}=true to confirm."
|
|
857
|
+
),
|
|
858
|
+
"linked_account_count": impact.linked_accounts,
|
|
859
|
+
"locked_out_account_count": impact.locked_out_accounts,
|
|
860
|
+
},
|
|
861
|
+
status=status.HTTP_409_CONFLICT,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Deleting the SocialApp cascades to the policy (OneToOne, CASCADE), but
|
|
865
|
+
# SocialAccount has no FK to SocialApp, so the per-user bindings would
|
|
866
|
+
# survive and could silently re-activate if the slug were later reused.
|
|
867
|
+
# Purge them explicitly, keyed on the provider slug.
|
|
868
|
+
#
|
|
869
|
+
# The purge is load-bearing, not merely hygiene: it is what makes the
|
|
870
|
+
# documented remove-and-re-add issuer migration work. A retained binding keeps
|
|
871
|
+
# its old ``extra_data["iss"]``, and ``resolve_user`` fails an issuer mismatch
|
|
872
|
+
# closed, so keeping the rows would lock every user out of the *new* issuer
|
|
873
|
+
# permanently instead of letting them re-link by verified email.
|
|
874
|
+
# Capture the audit payload BEFORE the cascade. Deleting the SocialApp cascades
|
|
875
|
+
# to the policy, and Django's collector nulls the pk on the instances it was
|
|
876
|
+
# handed, so a row assembled after this point reads a half-torn-down object
|
|
877
|
+
# (#3552, ADR-1120). Both impact counts are recorded: the delete destroys
|
|
878
|
+
# ``linked_accounts`` federated credentials, of which the locked-out set is a
|
|
879
|
+
# strict subset, and recording only the smaller number understates the blast
|
|
880
|
+
# radius on the sole record of an unrecoverable action. Both stay counts —
|
|
881
|
+
# naming the affected members in an audit row is a privacy call not taken here.
|
|
882
|
+
_record_provider_audit(
|
|
883
|
+
request,
|
|
884
|
+
event_type=AuditEventType.SSO_PROVIDER_DELETED,
|
|
885
|
+
policy=policy,
|
|
886
|
+
slug=slug,
|
|
887
|
+
metadata={
|
|
888
|
+
"linked_accounts": impact.linked_accounts,
|
|
889
|
+
"locked_out_accounts": impact.locked_out_accounts,
|
|
890
|
+
"confirmed_lockout": confirmed,
|
|
891
|
+
"config": {k: _cap(v) for k, v in _provider_snapshot(policy).items()},
|
|
892
|
+
},
|
|
893
|
+
)
|
|
894
|
+
SocialAccount.objects.filter(provider=slug).delete()
|
|
895
|
+
policy.social_app.delete()
|
|
896
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
class SsoTestConnectionView(IdempotencyMixin, APIView):
|
|
900
|
+
"""``POST /workspace/sso/providers/{slug}/test-connection/`` — probe reachability.
|
|
901
|
+
|
|
902
|
+
Session/JWT only, like the rest of the admin surface (#3551 — see the section
|
|
903
|
+
comment above). Included even though the probe mutates nothing: it is server-side
|
|
904
|
+
egress an admin's leaked token could aim at an arbitrary configured issuer, and
|
|
905
|
+
leaving one method of the surface token-reachable is how a guard erodes.
|
|
906
|
+
"""
|
|
907
|
+
|
|
908
|
+
permission_classes = [IsAuthenticated, IsNotTokenAuthenticated, IsWorkspaceAdminStrict]
|
|
909
|
+
# Throttled: the probe triggers server-side egress (OIDC discovery + JWKS, or
|
|
910
|
+
# the GitHub API), so an admin must not be able to drive unbounded outbound
|
|
911
|
+
# requests. Scoped like the flow endpoints (settings ``sso_test_connection``).
|
|
912
|
+
throttle_classes = [ScopedRateThrottle]
|
|
913
|
+
throttle_scope = "sso_test_connection"
|
|
914
|
+
# Exempt from the generic Idempotency-Key path (ADR-0170): a read-only
|
|
915
|
+
# reachability probe that mutates nothing.
|
|
916
|
+
idempotency_exempt = True
|
|
917
|
+
|
|
918
|
+
@extend_schema(
|
|
919
|
+
summary="Test a provider's discovery/JWKS (OIDC) or API (GitHub) reachability",
|
|
920
|
+
request=None,
|
|
921
|
+
responses={200: SsoTestConnectionResponseSerializer},
|
|
922
|
+
tags=["workspace"],
|
|
923
|
+
)
|
|
924
|
+
def post(self, request: Request, slug: str, *args: Any, **kwargs: Any) -> Response:
|
|
925
|
+
policy = _policy_or_none(slug)
|
|
926
|
+
if policy is None:
|
|
927
|
+
# Match the detail views' not-found behavior for an unknown slug.
|
|
928
|
+
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
929
|
+
from trueppm_api.apps.sso.services import ProviderContext
|
|
930
|
+
|
|
931
|
+
ctx = ProviderContext(social_app=policy.social_app, policy=policy)
|
|
932
|
+
if not ctx.is_github and not ctx.issuer:
|
|
933
|
+
return Response({"ok": False, "error": "no_issuer"})
|
|
934
|
+
result = services.check_provider_reachability(ctx)
|
|
935
|
+
return Response(SsoTestConnectionResponseSerializer(result).data)
|