arize-phoenix 10.0.4__py3-none-any.whl → 12.28.1__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.
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +124 -72
- arize_phoenix-12.28.1.dist-info/RECORD +499 -0
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
- phoenix/__generated__/__init__.py +0 -0
- phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
- phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
- phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
- phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
- phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
- phoenix/__init__.py +5 -4
- phoenix/auth.py +39 -2
- phoenix/config.py +1763 -91
- phoenix/datetime_utils.py +120 -2
- phoenix/db/README.md +595 -25
- phoenix/db/bulk_inserter.py +145 -103
- phoenix/db/engines.py +140 -33
- phoenix/db/enums.py +3 -12
- phoenix/db/facilitator.py +302 -35
- phoenix/db/helpers.py +1000 -65
- phoenix/db/iam_auth.py +64 -0
- phoenix/db/insertion/dataset.py +135 -2
- phoenix/db/insertion/document_annotation.py +9 -6
- phoenix/db/insertion/evaluation.py +2 -3
- phoenix/db/insertion/helpers.py +17 -2
- phoenix/db/insertion/session_annotation.py +176 -0
- phoenix/db/insertion/span.py +15 -11
- phoenix/db/insertion/span_annotation.py +3 -4
- phoenix/db/insertion/trace_annotation.py +3 -4
- phoenix/db/insertion/types.py +50 -20
- phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
- phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
- phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
- phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
- phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
- phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
- phoenix/db/migrations/versions/a20694b15f82_cost.py +196 -0
- phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
- phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
- phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
- phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
- phoenix/db/models.py +669 -56
- phoenix/db/pg_config.py +10 -0
- phoenix/db/types/model_provider.py +4 -0
- phoenix/db/types/token_price_customization.py +29 -0
- phoenix/db/types/trace_retention.py +23 -15
- phoenix/experiments/evaluators/utils.py +3 -3
- phoenix/experiments/functions.py +160 -52
- phoenix/experiments/tracing.py +2 -2
- phoenix/experiments/types.py +1 -1
- phoenix/inferences/inferences.py +1 -2
- phoenix/server/api/auth.py +38 -7
- phoenix/server/api/auth_messages.py +46 -0
- phoenix/server/api/context.py +100 -4
- phoenix/server/api/dataloaders/__init__.py +79 -5
- phoenix/server/api/dataloaders/annotation_configs_by_project.py +31 -0
- phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
- phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
- phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
- phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
- phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
- phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
- phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
- phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
- phoenix/server/api/dataloaders/dataset_labels.py +36 -0
- phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
- phoenix/server/api/dataloaders/document_evaluations.py +6 -9
- phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
- phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
- phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
- phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
- phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
- phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
- phoenix/server/api/dataloaders/last_used_times_by_generative_model_id.py +35 -0
- phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
- phoenix/server/api/dataloaders/record_counts.py +37 -10
- phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
- phoenix/server/api/dataloaders/span_cost_by_span.py +24 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_generative_model.py +56 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_project_session.py +57 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_span.py +43 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_trace.py +56 -0
- phoenix/server/api/dataloaders/span_cost_details_by_span_cost.py +27 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +57 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment_run.py +58 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_generative_model.py +55 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_project.py +152 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_project_session.py +56 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_trace.py +55 -0
- phoenix/server/api/dataloaders/span_costs.py +29 -0
- phoenix/server/api/dataloaders/table_fields.py +2 -2
- phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
- phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
- phoenix/server/api/dataloaders/types.py +29 -0
- phoenix/server/api/exceptions.py +11 -1
- phoenix/server/api/helpers/dataset_helpers.py +5 -1
- phoenix/server/api/helpers/playground_clients.py +1243 -292
- phoenix/server/api/helpers/playground_registry.py +2 -2
- phoenix/server/api/helpers/playground_spans.py +8 -4
- phoenix/server/api/helpers/playground_users.py +26 -0
- phoenix/server/api/helpers/prompts/conversions/aws.py +83 -0
- phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
- phoenix/server/api/helpers/prompts/models.py +205 -22
- phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
- phoenix/server/api/input_types/ChatCompletionInput.py +6 -2
- phoenix/server/api/input_types/CreateProjectInput.py +27 -0
- phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
- phoenix/server/api/input_types/DatasetFilter.py +17 -0
- phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
- phoenix/server/api/input_types/GenerativeCredentialInput.py +9 -0
- phoenix/server/api/input_types/GenerativeModelInput.py +5 -0
- phoenix/server/api/input_types/ProjectSessionSort.py +161 -1
- phoenix/server/api/input_types/PromptFilter.py +14 -0
- phoenix/server/api/input_types/PromptVersionInput.py +52 -1
- phoenix/server/api/input_types/SpanSort.py +44 -7
- phoenix/server/api/input_types/TimeBinConfig.py +23 -0
- phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
- phoenix/server/api/input_types/UserRoleInput.py +1 -0
- phoenix/server/api/mutations/__init__.py +10 -0
- phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
- phoenix/server/api/mutations/api_key_mutations.py +19 -23
- phoenix/server/api/mutations/chat_mutations.py +154 -47
- phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
- phoenix/server/api/mutations/dataset_mutations.py +21 -16
- phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
- phoenix/server/api/mutations/experiment_mutations.py +2 -2
- phoenix/server/api/mutations/export_events_mutations.py +3 -3
- phoenix/server/api/mutations/model_mutations.py +210 -0
- phoenix/server/api/mutations/project_mutations.py +49 -10
- phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
- phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
- phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
- phoenix/server/api/mutations/prompt_mutations.py +65 -129
- phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
- phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
- phoenix/server/api/mutations/trace_annotations_mutations.py +14 -10
- phoenix/server/api/mutations/trace_mutations.py +47 -3
- phoenix/server/api/mutations/user_mutations.py +66 -41
- phoenix/server/api/queries.py +768 -293
- phoenix/server/api/routers/__init__.py +2 -2
- phoenix/server/api/routers/auth.py +154 -88
- phoenix/server/api/routers/ldap.py +229 -0
- phoenix/server/api/routers/oauth2.py +369 -106
- phoenix/server/api/routers/v1/__init__.py +24 -4
- phoenix/server/api/routers/v1/annotation_configs.py +23 -31
- phoenix/server/api/routers/v1/annotations.py +481 -17
- phoenix/server/api/routers/v1/datasets.py +395 -81
- phoenix/server/api/routers/v1/documents.py +142 -0
- phoenix/server/api/routers/v1/evaluations.py +24 -31
- phoenix/server/api/routers/v1/experiment_evaluations.py +19 -8
- phoenix/server/api/routers/v1/experiment_runs.py +337 -59
- phoenix/server/api/routers/v1/experiments.py +479 -48
- phoenix/server/api/routers/v1/models.py +7 -0
- phoenix/server/api/routers/v1/projects.py +18 -49
- phoenix/server/api/routers/v1/prompts.py +54 -40
- phoenix/server/api/routers/v1/sessions.py +108 -0
- phoenix/server/api/routers/v1/spans.py +1091 -81
- phoenix/server/api/routers/v1/traces.py +132 -78
- phoenix/server/api/routers/v1/users.py +389 -0
- phoenix/server/api/routers/v1/utils.py +3 -7
- phoenix/server/api/subscriptions.py +305 -88
- phoenix/server/api/types/Annotation.py +90 -23
- phoenix/server/api/types/ApiKey.py +13 -17
- phoenix/server/api/types/AuthMethod.py +1 -0
- phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
- phoenix/server/api/types/CostBreakdown.py +12 -0
- phoenix/server/api/types/Dataset.py +226 -72
- phoenix/server/api/types/DatasetExample.py +88 -18
- phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
- phoenix/server/api/types/DatasetLabel.py +57 -0
- phoenix/server/api/types/DatasetSplit.py +98 -0
- phoenix/server/api/types/DatasetVersion.py +49 -4
- phoenix/server/api/types/DocumentAnnotation.py +212 -0
- phoenix/server/api/types/Experiment.py +264 -59
- phoenix/server/api/types/ExperimentComparison.py +5 -10
- phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
- phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
- phoenix/server/api/types/ExperimentRun.py +169 -65
- phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
- phoenix/server/api/types/GenerativeModel.py +245 -3
- phoenix/server/api/types/GenerativeProvider.py +70 -11
- phoenix/server/api/types/{Model.py → InferenceModel.py} +1 -1
- phoenix/server/api/types/ModelInterface.py +16 -0
- phoenix/server/api/types/PlaygroundModel.py +20 -0
- phoenix/server/api/types/Project.py +1278 -216
- phoenix/server/api/types/ProjectSession.py +188 -28
- phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
- phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
- phoenix/server/api/types/Prompt.py +119 -39
- phoenix/server/api/types/PromptLabel.py +42 -25
- phoenix/server/api/types/PromptVersion.py +11 -8
- phoenix/server/api/types/PromptVersionTag.py +65 -25
- phoenix/server/api/types/ServerStatus.py +6 -0
- phoenix/server/api/types/Span.py +167 -123
- phoenix/server/api/types/SpanAnnotation.py +189 -42
- phoenix/server/api/types/SpanCostDetailSummaryEntry.py +10 -0
- phoenix/server/api/types/SpanCostSummary.py +10 -0
- phoenix/server/api/types/SystemApiKey.py +65 -1
- phoenix/server/api/types/TokenPrice.py +16 -0
- phoenix/server/api/types/TokenUsage.py +3 -3
- phoenix/server/api/types/Trace.py +223 -51
- phoenix/server/api/types/TraceAnnotation.py +149 -50
- phoenix/server/api/types/User.py +137 -32
- phoenix/server/api/types/UserApiKey.py +73 -26
- phoenix/server/api/types/node.py +10 -0
- phoenix/server/api/types/pagination.py +11 -2
- phoenix/server/app.py +290 -45
- phoenix/server/authorization.py +38 -3
- phoenix/server/bearer_auth.py +34 -24
- phoenix/server/cost_tracking/cost_details_calculator.py +196 -0
- phoenix/server/cost_tracking/cost_model_lookup.py +179 -0
- phoenix/server/cost_tracking/helpers.py +68 -0
- phoenix/server/cost_tracking/model_cost_manifest.json +3657 -830
- phoenix/server/cost_tracking/regex_specificity.py +397 -0
- phoenix/server/cost_tracking/token_cost_calculator.py +57 -0
- phoenix/server/daemons/__init__.py +0 -0
- phoenix/server/daemons/db_disk_usage_monitor.py +214 -0
- phoenix/server/daemons/generative_model_store.py +103 -0
- phoenix/server/daemons/span_cost_calculator.py +99 -0
- phoenix/server/dml_event.py +17 -0
- phoenix/server/dml_event_handler.py +5 -0
- phoenix/server/email/sender.py +56 -3
- phoenix/server/email/templates/db_disk_usage_notification.html +19 -0
- phoenix/server/email/types.py +11 -0
- phoenix/server/experiments/__init__.py +0 -0
- phoenix/server/experiments/utils.py +14 -0
- phoenix/server/grpc_server.py +11 -11
- phoenix/server/jwt_store.py +17 -15
- phoenix/server/ldap.py +1449 -0
- phoenix/server/main.py +26 -10
- phoenix/server/oauth2.py +330 -12
- phoenix/server/prometheus.py +66 -6
- phoenix/server/rate_limiters.py +4 -9
- phoenix/server/retention.py +33 -20
- phoenix/server/session_filters.py +49 -0
- phoenix/server/static/.vite/manifest.json +55 -51
- phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
- phoenix/server/static/assets/{index-E0M82BdE.js → index-CTQoemZv.js} +140 -56
- phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
- phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
- phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
- phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
- phoenix/server/static/assets/vendor-recharts-V9cwpXsm.js +37 -0
- phoenix/server/static/assets/vendor-shiki-Do--csgv.js +5 -0
- phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
- phoenix/server/templates/index.html +40 -6
- phoenix/server/thread_server.py +1 -2
- phoenix/server/types.py +14 -4
- phoenix/server/utils.py +74 -0
- phoenix/session/client.py +56 -3
- phoenix/session/data_extractor.py +5 -0
- phoenix/session/evaluation.py +14 -5
- phoenix/session/session.py +45 -9
- phoenix/settings.py +5 -0
- phoenix/trace/attributes.py +80 -13
- phoenix/trace/dsl/helpers.py +90 -1
- phoenix/trace/dsl/query.py +8 -6
- phoenix/trace/projects.py +5 -0
- phoenix/utilities/template_formatters.py +1 -1
- phoenix/version.py +1 -1
- arize_phoenix-10.0.4.dist-info/RECORD +0 -405
- phoenix/server/api/types/Evaluation.py +0 -39
- phoenix/server/cost_tracking/cost_lookup.py +0 -255
- phoenix/server/static/assets/components-DULKeDfL.js +0 -4365
- phoenix/server/static/assets/pages-Cl0A-0U2.js +0 -7430
- phoenix/server/static/assets/vendor-WIZid84E.css +0 -1
- phoenix/server/static/assets/vendor-arizeai-Dy-0mSNw.js +0 -649
- phoenix/server/static/assets/vendor-codemirror-DBtifKNr.js +0 -33
- phoenix/server/static/assets/vendor-oB4u9zuV.js +0 -905
- phoenix/server/static/assets/vendor-recharts-D-T4KPz2.js +0 -59
- phoenix/server/static/assets/vendor-shiki-BMn4O_9F.js +0 -5
- phoenix/server/static/assets/vendor-three-C5WAXd5r.js +0 -2998
- phoenix/utilities/deprecation.py +0 -31
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
from .auth import
|
|
1
|
+
from .auth import create_auth_router
|
|
2
2
|
from .embeddings import create_embeddings_router
|
|
3
3
|
from .oauth2 import router as oauth2_router
|
|
4
4
|
from .v1 import create_v1_router
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
|
-
"
|
|
7
|
+
"create_auth_router",
|
|
8
8
|
"create_embeddings_router",
|
|
9
9
|
"create_v1_router",
|
|
10
10
|
"oauth2_router",
|
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import logging
|
|
2
3
|
import secrets
|
|
3
4
|
from datetime import datetime, timedelta, timezone
|
|
4
5
|
from functools import partial
|
|
5
|
-
from
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
6
7
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
7
8
|
|
|
8
9
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
9
|
-
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy import func, select
|
|
10
11
|
from sqlalchemy.orm import joinedload
|
|
11
|
-
from starlette.status import (
|
|
12
|
-
HTTP_204_NO_CONTENT,
|
|
13
|
-
HTTP_401_UNAUTHORIZED,
|
|
14
|
-
HTTP_403_FORBIDDEN,
|
|
15
|
-
HTTP_404_NOT_FOUND,
|
|
16
|
-
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
17
|
-
HTTP_503_SERVICE_UNAVAILABLE,
|
|
18
|
-
)
|
|
19
12
|
|
|
20
13
|
from phoenix.auth import (
|
|
21
14
|
DEFAULT_SECRET_LENGTH,
|
|
@@ -28,6 +21,7 @@ from phoenix.auth import (
|
|
|
28
21
|
delete_oauth2_state_cookie,
|
|
29
22
|
delete_refresh_token_cookie,
|
|
30
23
|
is_valid_password,
|
|
24
|
+
sanitize_email,
|
|
31
25
|
set_access_token_cookie,
|
|
32
26
|
set_refresh_token_cookie,
|
|
33
27
|
validate_password_format,
|
|
@@ -36,9 +30,9 @@ from phoenix.config import (
|
|
|
36
30
|
get_base_url,
|
|
37
31
|
get_env_disable_basic_auth,
|
|
38
32
|
get_env_disable_rate_limit,
|
|
39
|
-
get_env_host_root_path,
|
|
40
33
|
)
|
|
41
34
|
from phoenix.db import models
|
|
35
|
+
from phoenix.server.api.routers.ldap import get_or_create_ldap_user
|
|
42
36
|
from phoenix.server.bearer_auth import PhoenixUser, create_access_and_refresh_tokens
|
|
43
37
|
from phoenix.server.email.types import EmailSender
|
|
44
38
|
from phoenix.server.rate_limiters import ServerRateLimiter, fastapi_ip_rate_limiter
|
|
@@ -50,6 +44,12 @@ from phoenix.server.types import (
|
|
|
50
44
|
TokenStore,
|
|
51
45
|
UserId,
|
|
52
46
|
)
|
|
47
|
+
from phoenix.server.utils import prepend_root_path
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from phoenix.server.ldap import LDAPAuthenticator
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
53
|
|
|
54
54
|
rate_limiter = ServerRateLimiter(
|
|
55
55
|
per_second_rate_limit=0.2,
|
|
@@ -57,73 +57,92 @@ rate_limiter = ServerRateLimiter(
|
|
|
57
57
|
partition_seconds=60,
|
|
58
58
|
active_partitions=2,
|
|
59
59
|
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_auth_router(ldap_enabled: bool = False) -> APIRouter:
|
|
63
|
+
"""Create auth router with all authentication endpoints.
|
|
64
|
+
|
|
65
|
+
Creates a fresh router instance each time to avoid global state issues
|
|
66
|
+
(e.g., route accumulation in tests).
|
|
67
|
+
|
|
68
|
+
Security: Only registers the /ldap/login endpoint when LDAP is actually configured.
|
|
69
|
+
This prevents information disclosure and reduces attack surface.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
ldap_enabled: Whether LDAP authentication is configured
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
APIRouter: Authentication router with all endpoints registered
|
|
76
|
+
"""
|
|
77
|
+
# Build rate limiter paths based on configuration
|
|
78
|
+
rate_limited_paths = [
|
|
63
79
|
"/auth/login",
|
|
64
80
|
"/auth/logout",
|
|
65
81
|
"/auth/refresh",
|
|
66
82
|
"/auth/password-reset-email",
|
|
67
83
|
"/auth/password-reset",
|
|
68
|
-
]
|
|
69
|
-
|
|
84
|
+
]
|
|
85
|
+
if ldap_enabled:
|
|
86
|
+
rate_limited_paths.append("/auth/ldap/login")
|
|
87
|
+
|
|
88
|
+
login_rate_limiter = fastapi_ip_rate_limiter(rate_limiter, paths=rate_limited_paths)
|
|
89
|
+
auth_dependencies = [Depends(login_rate_limiter)] if not get_env_disable_rate_limit() else []
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
|
|
91
|
+
router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
|
|
73
92
|
|
|
93
|
+
# Register all authentication endpoints
|
|
94
|
+
router.add_api_route("/login", _login, methods=["POST"])
|
|
95
|
+
router.add_api_route("/logout", _logout, methods=["GET"])
|
|
96
|
+
router.add_api_route("/refresh", _refresh_tokens, methods=["POST"])
|
|
97
|
+
router.add_api_route("/password-reset-email", _initiate_password_reset, methods=["POST"])
|
|
98
|
+
router.add_api_route("/password-reset", _reset_password, methods=["POST"])
|
|
74
99
|
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
# Conditionally add LDAP endpoint only if configured
|
|
101
|
+
if ldap_enabled:
|
|
102
|
+
router.add_api_route("/ldap/login", _ldap_login, methods=["POST"])
|
|
103
|
+
|
|
104
|
+
return router
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _login(request: Request) -> Response:
|
|
108
|
+
"""Authenticate user via email/password and return access/refresh tokens."""
|
|
77
109
|
if get_env_disable_basic_auth():
|
|
78
|
-
raise HTTPException(status_code=
|
|
79
|
-
assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
|
|
80
|
-
assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
|
|
81
|
-
token_store: TokenStore = request.app.state.get_token_store()
|
|
110
|
+
raise HTTPException(status_code=403)
|
|
82
111
|
data = await request.json()
|
|
83
112
|
email = data.get("email")
|
|
84
113
|
password = data.get("password")
|
|
85
114
|
|
|
86
115
|
if not email or not password:
|
|
87
|
-
raise HTTPException(status_code=
|
|
116
|
+
raise HTTPException(status_code=401, detail="Email and password required")
|
|
117
|
+
|
|
118
|
+
# Sanitize email by trimming and lowercasing
|
|
119
|
+
email = sanitize_email(email)
|
|
88
120
|
|
|
89
121
|
async with request.app.state.db() as session:
|
|
90
122
|
user = await session.scalar(
|
|
91
|
-
select(models.User)
|
|
123
|
+
select(models.User)
|
|
124
|
+
.where(func.lower(models.User.email) == email)
|
|
125
|
+
.options(joinedload(models.User.role))
|
|
92
126
|
)
|
|
93
127
|
if (
|
|
94
128
|
user is None
|
|
95
129
|
or (password_hash := user.password_hash) is None
|
|
96
130
|
or (salt := user.password_salt) is None
|
|
97
131
|
):
|
|
98
|
-
raise HTTPException(status_code=
|
|
132
|
+
raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
|
|
99
133
|
|
|
100
134
|
loop = asyncio.get_running_loop()
|
|
101
135
|
password_is_valid = partial(
|
|
102
136
|
is_valid_password, password=password, salt=salt, password_hash=password_hash
|
|
103
137
|
)
|
|
104
138
|
if not await loop.run_in_executor(None, password_is_valid):
|
|
105
|
-
raise HTTPException(status_code=
|
|
139
|
+
raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
|
|
106
140
|
|
|
107
|
-
|
|
108
|
-
token_store=token_store,
|
|
109
|
-
user=user,
|
|
110
|
-
access_token_expiry=access_token_expiry,
|
|
111
|
-
refresh_token_expiry=refresh_token_expiry,
|
|
112
|
-
)
|
|
113
|
-
response = Response(status_code=HTTP_204_NO_CONTENT)
|
|
114
|
-
response = set_access_token_cookie(
|
|
115
|
-
response=response, access_token=access_token, max_age=access_token_expiry
|
|
116
|
-
)
|
|
117
|
-
response = set_refresh_token_cookie(
|
|
118
|
-
response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
|
|
119
|
-
)
|
|
120
|
-
return response
|
|
141
|
+
return await _create_auth_response(request, user)
|
|
121
142
|
|
|
122
143
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
request: Request,
|
|
126
|
-
) -> Response:
|
|
144
|
+
async def _logout(request: Request) -> Response:
|
|
145
|
+
"""Log out user by revoking tokens and clearing cookies."""
|
|
127
146
|
token_store: TokenStore = request.app.state.get_token_store()
|
|
128
147
|
user_id = None
|
|
129
148
|
if isinstance(user := request.user, PhoenixUser):
|
|
@@ -138,7 +157,9 @@ async def logout(
|
|
|
138
157
|
user_id = subject
|
|
139
158
|
if user_id:
|
|
140
159
|
await token_store.log_out(user_id)
|
|
141
|
-
|
|
160
|
+
redirect_path = "/logout" if get_env_disable_basic_auth() else "/login"
|
|
161
|
+
redirect_url = prepend_root_path(request.scope, redirect_path)
|
|
162
|
+
response = Response(status_code=302, headers={"Location": redirect_url})
|
|
142
163
|
response = delete_access_token_cookie(response)
|
|
143
164
|
response = delete_refresh_token_cookie(response)
|
|
144
165
|
response = delete_oauth2_state_cookie(response)
|
|
@@ -146,12 +167,10 @@ async def logout(
|
|
|
146
167
|
return response
|
|
147
168
|
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
|
|
152
|
-
assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
|
|
170
|
+
async def _refresh_tokens(request: Request) -> Response:
|
|
171
|
+
"""Refresh access and refresh tokens."""
|
|
153
172
|
if (refresh_token := request.cookies.get(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)) is None:
|
|
154
|
-
raise HTTPException(status_code=
|
|
173
|
+
raise HTTPException(status_code=401, detail="Missing refresh token")
|
|
155
174
|
token_store: TokenStore = request.app.state.get_token_store()
|
|
156
175
|
refresh_token_claims = await token_store.read(Token(refresh_token))
|
|
157
176
|
if (
|
|
@@ -161,9 +180,9 @@ async def refresh_tokens(request: Request) -> Response:
|
|
|
161
180
|
or (user_id := int(refresh_token_claims.subject)) is None
|
|
162
181
|
or (expiration_time := refresh_token_claims.expiration_time) is None
|
|
163
182
|
):
|
|
164
|
-
raise HTTPException(status_code=
|
|
165
|
-
if expiration_time.timestamp()
|
|
166
|
-
raise HTTPException(status_code=
|
|
183
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
184
|
+
if expiration_time.timestamp() <= datetime.now(timezone.utc).timestamp():
|
|
185
|
+
raise HTTPException(status_code=401, detail="Expired refresh token")
|
|
167
186
|
await token_store.revoke(refresh_token_id)
|
|
168
187
|
|
|
169
188
|
if (
|
|
@@ -181,30 +200,22 @@ async def refresh_tokens(request: Request) -> Response:
|
|
|
181
200
|
select(models.User).filter_by(id=user_id).options(joinedload(models.User.role))
|
|
182
201
|
)
|
|
183
202
|
) is None:
|
|
184
|
-
raise HTTPException(status_code=
|
|
185
|
-
access_token, refresh_token = await create_access_and_refresh_tokens(
|
|
186
|
-
token_store=token_store,
|
|
187
|
-
user=user,
|
|
188
|
-
access_token_expiry=access_token_expiry,
|
|
189
|
-
refresh_token_expiry=refresh_token_expiry,
|
|
190
|
-
)
|
|
191
|
-
response = Response(status_code=HTTP_204_NO_CONTENT)
|
|
192
|
-
response = set_access_token_cookie(
|
|
193
|
-
response=response, access_token=access_token, max_age=access_token_expiry
|
|
194
|
-
)
|
|
195
|
-
response = set_refresh_token_cookie(
|
|
196
|
-
response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
|
|
197
|
-
)
|
|
198
|
-
return response
|
|
203
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
199
204
|
|
|
205
|
+
return await _create_auth_response(request, user)
|
|
200
206
|
|
|
201
|
-
|
|
202
|
-
async def
|
|
207
|
+
|
|
208
|
+
async def _initiate_password_reset(request: Request) -> Response:
|
|
209
|
+
"""Send password reset email to user."""
|
|
203
210
|
if get_env_disable_basic_auth():
|
|
204
|
-
raise HTTPException(status_code=
|
|
211
|
+
raise HTTPException(status_code=403)
|
|
205
212
|
data = await request.json()
|
|
206
213
|
if not (email := data.get("email")):
|
|
207
214
|
raise MISSING_EMAIL
|
|
215
|
+
|
|
216
|
+
# Sanitize email by trimming and lowercasing
|
|
217
|
+
email = sanitize_email(email)
|
|
218
|
+
|
|
208
219
|
sender: EmailSender = request.app.state.email_sender
|
|
209
220
|
if sender is None:
|
|
210
221
|
raise SMTP_UNAVAILABLE
|
|
@@ -212,14 +223,14 @@ async def initiate_password_reset(request: Request) -> Response:
|
|
|
212
223
|
async with request.app.state.db() as session:
|
|
213
224
|
user = await session.scalar(
|
|
214
225
|
select(models.User)
|
|
215
|
-
.
|
|
226
|
+
.where(func.lower(models.User.email) == email)
|
|
216
227
|
.options(
|
|
217
228
|
joinedload(models.User.password_reset_token).load_only(models.PasswordResetToken.id)
|
|
218
229
|
)
|
|
219
230
|
)
|
|
220
231
|
if user is None or user.auth_method != "LOCAL":
|
|
221
232
|
# Withold privileged information
|
|
222
|
-
return Response(status_code=
|
|
233
|
+
return Response(status_code=204)
|
|
223
234
|
token_store: TokenStore = request.app.state.get_token_store()
|
|
224
235
|
if user.password_reset_token:
|
|
225
236
|
await token_store.revoke(PasswordResetTokenId(user.password_reset_token.id))
|
|
@@ -230,18 +241,18 @@ async def initiate_password_reset(request: Request) -> Response:
|
|
|
230
241
|
)
|
|
231
242
|
token, _ = await token_store.create_password_reset_token(password_reset_token_claims)
|
|
232
243
|
url = urlparse(request.headers.get("referer") or get_base_url())
|
|
233
|
-
path =
|
|
244
|
+
path = prepend_root_path(request.scope, "/reset-password-with-token")
|
|
234
245
|
query_string = urlencode(dict(token=token))
|
|
235
|
-
components = (url.scheme, url.netloc, path
|
|
246
|
+
components = (url.scheme, url.netloc, path, "", query_string, "")
|
|
236
247
|
reset_url = urlunparse(components)
|
|
237
248
|
await sender.send_password_reset_email(email, reset_url)
|
|
238
|
-
return Response(status_code=
|
|
249
|
+
return Response(status_code=204)
|
|
239
250
|
|
|
240
251
|
|
|
241
|
-
|
|
242
|
-
|
|
252
|
+
async def _reset_password(request: Request) -> Response:
|
|
253
|
+
"""Reset user password using a valid reset token."""
|
|
243
254
|
if get_env_disable_basic_auth():
|
|
244
|
-
raise HTTPException(status_code=
|
|
255
|
+
raise HTTPException(status_code=403)
|
|
245
256
|
data = await request.json()
|
|
246
257
|
if not (password := data.get("password")):
|
|
247
258
|
raise MISSING_PASSWORD
|
|
@@ -250,7 +261,7 @@ async def reset_password(request: Request) -> Response:
|
|
|
250
261
|
not (token := data.get("token"))
|
|
251
262
|
or not isinstance((claims := await token_store.read(token)), PasswordResetTokenClaims)
|
|
252
263
|
or not claims.expiration_time
|
|
253
|
-
or claims.expiration_time
|
|
264
|
+
or claims.expiration_time <= datetime.now(timezone.utc)
|
|
254
265
|
):
|
|
255
266
|
raise INVALID_TOKEN
|
|
256
267
|
assert (user_id := claims.subject)
|
|
@@ -258,7 +269,7 @@ async def reset_password(request: Request) -> Response:
|
|
|
258
269
|
user = await session.scalar(select(models.User).filter_by(id=int(user_id)))
|
|
259
270
|
if user is None or user.auth_method != "LOCAL":
|
|
260
271
|
# Withold privileged information
|
|
261
|
-
return Response(status_code=
|
|
272
|
+
return Response(status_code=204)
|
|
262
273
|
validate_password_format(password)
|
|
263
274
|
user.password_salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
|
|
264
275
|
loop = asyncio.get_running_loop()
|
|
@@ -269,28 +280,83 @@ async def reset_password(request: Request) -> Response:
|
|
|
269
280
|
async with request.app.state.db() as session:
|
|
270
281
|
session.add(user)
|
|
271
282
|
await session.flush()
|
|
272
|
-
response = Response(status_code=
|
|
283
|
+
response = Response(status_code=204)
|
|
273
284
|
assert (token_id := claims.token_id)
|
|
274
285
|
await token_store.revoke(token_id)
|
|
275
286
|
await token_store.log_out(UserId(user.id))
|
|
276
287
|
return response
|
|
277
288
|
|
|
278
289
|
|
|
290
|
+
async def _ldap_login(request: Request) -> Response:
|
|
291
|
+
"""Authenticate user via LDAP and return access/refresh tokens."""
|
|
292
|
+
# Use cached authenticator instance to avoid re-parsing TLS config on every request
|
|
293
|
+
authenticator: LDAPAuthenticator | None = getattr(request.app.state, "ldap_authenticator", None)
|
|
294
|
+
|
|
295
|
+
if not authenticator:
|
|
296
|
+
raise HTTPException(
|
|
297
|
+
status_code=503, detail="LDAP authentication is not configured on this server"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
data = await request.json()
|
|
301
|
+
username = data.get("username")
|
|
302
|
+
password = data.get("password")
|
|
303
|
+
|
|
304
|
+
if not username or not password:
|
|
305
|
+
raise HTTPException(status_code=401, detail="Username and password required")
|
|
306
|
+
|
|
307
|
+
# Authenticate against LDAP (reused authenticator, already parsed TLS config)
|
|
308
|
+
user_info = await authenticator.authenticate(username, password)
|
|
309
|
+
|
|
310
|
+
if not user_info:
|
|
311
|
+
# Generic error message to prevent username enumeration
|
|
312
|
+
raise HTTPException(status_code=401, detail="Invalid username and/or password")
|
|
313
|
+
|
|
314
|
+
# Get or create user in Phoenix database
|
|
315
|
+
async with request.app.state.db() as session:
|
|
316
|
+
user = await get_or_create_ldap_user(session, user_info, authenticator.config)
|
|
317
|
+
|
|
318
|
+
return await _create_auth_response(request, user)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def _create_auth_response(request: Request, user: models.User) -> Response:
|
|
322
|
+
"""
|
|
323
|
+
Creates access and refresh tokens for the user and sets them as cookies in the response.
|
|
324
|
+
"""
|
|
325
|
+
token_store: TokenStore = request.app.state.get_token_store()
|
|
326
|
+
assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
|
|
327
|
+
assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
|
|
328
|
+
|
|
329
|
+
access_token, refresh_token = await create_access_and_refresh_tokens(
|
|
330
|
+
token_store=token_store,
|
|
331
|
+
user=user,
|
|
332
|
+
access_token_expiry=access_token_expiry,
|
|
333
|
+
refresh_token_expiry=refresh_token_expiry,
|
|
334
|
+
)
|
|
335
|
+
response = Response(status_code=204)
|
|
336
|
+
response = set_access_token_cookie(
|
|
337
|
+
response=response, access_token=access_token, max_age=access_token_expiry
|
|
338
|
+
)
|
|
339
|
+
response = set_refresh_token_cookie(
|
|
340
|
+
response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
|
|
341
|
+
)
|
|
342
|
+
return response
|
|
343
|
+
|
|
344
|
+
|
|
279
345
|
LOGIN_FAILED_MESSAGE = "Invalid email and/or password"
|
|
280
346
|
|
|
281
347
|
MISSING_EMAIL = HTTPException(
|
|
282
|
-
status_code=
|
|
348
|
+
status_code=422,
|
|
283
349
|
detail="Email required",
|
|
284
350
|
)
|
|
285
351
|
MISSING_PASSWORD = HTTPException(
|
|
286
|
-
status_code=
|
|
352
|
+
status_code=422,
|
|
287
353
|
detail="Password required",
|
|
288
354
|
)
|
|
289
355
|
SMTP_UNAVAILABLE = HTTPException(
|
|
290
|
-
status_code=
|
|
356
|
+
status_code=503,
|
|
291
357
|
detail="SMTP server not configured",
|
|
292
358
|
)
|
|
293
359
|
INVALID_TOKEN = HTTPException(
|
|
294
|
-
status_code=
|
|
360
|
+
status_code=401,
|
|
295
361
|
detail="Invalid token",
|
|
296
362
|
)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import secrets
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
from sqlalchemy import func, select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
from sqlalchemy.orm import joinedload
|
|
9
|
+
|
|
10
|
+
from phoenix.auth import sanitize_email
|
|
11
|
+
from phoenix.config import LDAPConfig
|
|
12
|
+
from phoenix.db import models
|
|
13
|
+
from phoenix.server.ldap import (
|
|
14
|
+
LDAP_CLIENT_ID_MARKER,
|
|
15
|
+
LDAPUserInfo,
|
|
16
|
+
generate_null_email_marker,
|
|
17
|
+
is_ldap_user,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_or_create_ldap_user(
|
|
24
|
+
session: AsyncSession,
|
|
25
|
+
user_info: LDAPUserInfo,
|
|
26
|
+
ldap_config: LDAPConfig,
|
|
27
|
+
) -> models.User:
|
|
28
|
+
"""
|
|
29
|
+
Retrieves an existing LDAP user or creates a new one.
|
|
30
|
+
|
|
31
|
+
User Identity Strategy:
|
|
32
|
+
Phoenix identifies LDAP users using a stable identifier. The strategy
|
|
33
|
+
depends on whether PHOENIX_LDAP_ATTR_UNIQUE_ID is configured:
|
|
34
|
+
|
|
35
|
+
1. If PHOENIX_LDAP_ATTR_UNIQUE_ID is set (e.g., "objectGUID" or "entryUUID"):
|
|
36
|
+
- Stores the immutable LDAP unique ID in oauth2_user_id
|
|
37
|
+
- Primary lookup by oauth2_user_id, fallback by email
|
|
38
|
+
- Survives: DN changes, email changes, renames, OU moves, domain consolidation
|
|
39
|
+
- This is how enterprise IAM systems (Okta, Azure AD Connect) work
|
|
40
|
+
|
|
41
|
+
2. Otherwise (default):
|
|
42
|
+
- oauth2_user_id is NULL (no redundant email storage)
|
|
43
|
+
- Lookup by email column directly
|
|
44
|
+
- Survives: DN changes, OU moves, renames
|
|
45
|
+
- Simple setup for most organizations
|
|
46
|
+
|
|
47
|
+
Null Email Marker Mode:
|
|
48
|
+
When PHOENIX_LDAP_ATTR_EMAIL is "null", the LDAP directory doesn't have
|
|
49
|
+
email attributes. In this mode:
|
|
50
|
+
- unique_id is required (enforced at config validation)
|
|
51
|
+
- Lookup is by unique_id only (no email fallback)
|
|
52
|
+
- A null email marker is generated: "\\ue000NULL(stopgap){md5(unique_id)}"
|
|
53
|
+
|
|
54
|
+
Admin-Provisioned Users:
|
|
55
|
+
Admins can pre-create users with oauth2_user_id=NULL. On first login,
|
|
56
|
+
the user is matched by email and oauth2_user_id is populated (if unique_id
|
|
57
|
+
is configured). Not supported in null email marker mode.
|
|
58
|
+
"""
|
|
59
|
+
unique_id = user_info.unique_id # Required when email is None
|
|
60
|
+
|
|
61
|
+
# Determine the email to use for lookup and storage
|
|
62
|
+
# If user_info.email is None, we're in null email marker mode
|
|
63
|
+
email: str | None = sanitize_email(user_info.email) if user_info.email else None
|
|
64
|
+
|
|
65
|
+
# Step 1: Look up user
|
|
66
|
+
# Strategy depends on whether unique_id is configured
|
|
67
|
+
user: models.User | None = None
|
|
68
|
+
|
|
69
|
+
if unique_id:
|
|
70
|
+
# Enterprise mode (or null email marker mode): lookup by unique_id first
|
|
71
|
+
user = await _lookup_by_unique_id(session, unique_id)
|
|
72
|
+
|
|
73
|
+
# Fallback: email lookup (handles migration to unique_id)
|
|
74
|
+
# Skip this in null email marker mode (no real email to look up)
|
|
75
|
+
if not user and email:
|
|
76
|
+
user = await _lookup_by_email(session, email)
|
|
77
|
+
if user:
|
|
78
|
+
# SECURITY: Only migrate if user has no existing unique_id.
|
|
79
|
+
# This prevents an email recycling attack where a new user with
|
|
80
|
+
# a recycled email address could hijack an old user's account.
|
|
81
|
+
#
|
|
82
|
+
# Scenario without this check:
|
|
83
|
+
# 1. User A leaves company (DB: email=john@corp.com, uuid=UUID-A)
|
|
84
|
+
# 2. User B joins with recycled email (LDAP: email=john@corp.com, uuid=UUID-B)
|
|
85
|
+
# 3. User B logs in, email lookup finds User A, UUID-B overwrites UUID-A
|
|
86
|
+
# 4. User B now has access to User A's data!
|
|
87
|
+
#
|
|
88
|
+
# With this check:
|
|
89
|
+
# - User A already has uuid=UUID-A, so no migration happens
|
|
90
|
+
# - User B is rejected (403) - admin must resolve the conflict
|
|
91
|
+
# - Note: We can't create a new user because email is unique in DB
|
|
92
|
+
if user.oauth2_user_id is None:
|
|
93
|
+
user.oauth2_user_id = unique_id
|
|
94
|
+
elif user.oauth2_user_id.lower() != unique_id.lower():
|
|
95
|
+
# Email matches but unique_id differs - this is a DIFFERENT person
|
|
96
|
+
# (e.g., email recycled to new employee).
|
|
97
|
+
#
|
|
98
|
+
# We cannot create a new user because email is unique in the database.
|
|
99
|
+
# This requires admin intervention to resolve (e.g., delete/rename the
|
|
100
|
+
# old account, or update the old account's unique_id).
|
|
101
|
+
logger.error(
|
|
102
|
+
f"LDAP account conflict: user_id={user.id} has different unique_id. "
|
|
103
|
+
f"Admin must resolve (delete old account or update unique_id)."
|
|
104
|
+
)
|
|
105
|
+
raise HTTPException(
|
|
106
|
+
status_code=401,
|
|
107
|
+
detail="Invalid username and/or password",
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
# Same unique_id (case-insensitive match) - normalize case in DB
|
|
111
|
+
if user.oauth2_user_id != unique_id:
|
|
112
|
+
user.oauth2_user_id = unique_id
|
|
113
|
+
elif email:
|
|
114
|
+
# Simple mode: lookup by email only (oauth2_user_id is NULL)
|
|
115
|
+
user = await _lookup_by_email(session, email)
|
|
116
|
+
# else: neither unique_id nor email - this shouldn't happen (config validation prevents it)
|
|
117
|
+
|
|
118
|
+
# Step 2: Validate role exists
|
|
119
|
+
role = await session.scalar(
|
|
120
|
+
select(models.UserRole).where(models.UserRole.name == user_info.role)
|
|
121
|
+
)
|
|
122
|
+
if not role:
|
|
123
|
+
raise HTTPException(
|
|
124
|
+
status_code=500,
|
|
125
|
+
detail="Role not found in database",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Step 3: Update existing user attributes
|
|
129
|
+
if user:
|
|
130
|
+
# Sync email on every login (email may have changed in LDAP)
|
|
131
|
+
if email and user.email != email:
|
|
132
|
+
user.email = email
|
|
133
|
+
|
|
134
|
+
# Note: Do NOT sync username - it should remain stable
|
|
135
|
+
# Updating username could cause collisions if displayName changes in LDAP
|
|
136
|
+
|
|
137
|
+
# Update role if it changed
|
|
138
|
+
if user.role.name != role.name:
|
|
139
|
+
user.role = role
|
|
140
|
+
return user
|
|
141
|
+
|
|
142
|
+
# Step 4: Create new user (if sign-up is allowed)
|
|
143
|
+
if not ldap_config.allow_sign_up:
|
|
144
|
+
logger.info("LDAP user attempted to sign up but sign-up is not allowed")
|
|
145
|
+
raise HTTPException(
|
|
146
|
+
status_code=401,
|
|
147
|
+
detail="Invalid username and/or password",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Determine the email to store in the database
|
|
151
|
+
if email:
|
|
152
|
+
db_email = email
|
|
153
|
+
# Security: Check if email already exists with different auth method
|
|
154
|
+
existing_user = await session.scalar(
|
|
155
|
+
select(models.User).where(func.lower(models.User.email) == email.lower())
|
|
156
|
+
)
|
|
157
|
+
if existing_user and not is_ldap_user(existing_user.oauth2_client_id):
|
|
158
|
+
logger.error(
|
|
159
|
+
"Email already exists with different auth method: %s", existing_user.auth_method
|
|
160
|
+
)
|
|
161
|
+
raise HTTPException(
|
|
162
|
+
status_code=401,
|
|
163
|
+
detail="Invalid username and/or password",
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
# Null email marker mode: generate deterministic marker from unique_id
|
|
167
|
+
# unique_id is guaranteed to be set (config validation ensures this)
|
|
168
|
+
if not unique_id:
|
|
169
|
+
raise ValueError("unique_id required when email is None")
|
|
170
|
+
db_email = generate_null_email_marker(unique_id)
|
|
171
|
+
|
|
172
|
+
# Username strategy: Try displayName first (user-friendly), handle collisions gracefully
|
|
173
|
+
username = user_info.display_name
|
|
174
|
+
existing_username = await session.scalar(
|
|
175
|
+
select(models.User).where(models.User.username == username)
|
|
176
|
+
)
|
|
177
|
+
if existing_username:
|
|
178
|
+
# Collision detected - append short suffix to make unique
|
|
179
|
+
username = f"{user_info.display_name} ({secrets.token_hex(3)})"
|
|
180
|
+
|
|
181
|
+
user = models.User(
|
|
182
|
+
email=db_email,
|
|
183
|
+
username=username,
|
|
184
|
+
role=role,
|
|
185
|
+
reset_password=False,
|
|
186
|
+
auth_method="OAUTH2", # TODO: change to LDAP in future db migration
|
|
187
|
+
oauth2_client_id=LDAP_CLIENT_ID_MARKER,
|
|
188
|
+
oauth2_user_id=unique_id, # None if unique_id not configured (use email column)
|
|
189
|
+
)
|
|
190
|
+
session.add(user)
|
|
191
|
+
return user
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _lookup_by_unique_id(session: AsyncSession, unique_id: str) -> models.User | None:
|
|
195
|
+
"""Look up LDAP user by immutable unique ID (objectGUID, entryUUID, etc.).
|
|
196
|
+
|
|
197
|
+
Uses case-insensitive comparison because:
|
|
198
|
+
- UUIDs are case-insensitive per RFC 4122
|
|
199
|
+
- Older versions may have stored uppercase UUIDs
|
|
200
|
+
- Current code normalizes to lowercase
|
|
201
|
+
|
|
202
|
+
This ensures users aren't locked out due to case differences.
|
|
203
|
+
"""
|
|
204
|
+
return cast(
|
|
205
|
+
models.User | None,
|
|
206
|
+
await session.scalar(
|
|
207
|
+
select(models.User)
|
|
208
|
+
.where(models.User.oauth2_client_id == LDAP_CLIENT_ID_MARKER)
|
|
209
|
+
.where(func.lower(models.User.oauth2_user_id) == unique_id.lower())
|
|
210
|
+
.options(joinedload(models.User.role))
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _lookup_by_email(session: AsyncSession, email: str) -> models.User | None:
|
|
216
|
+
"""Look up LDAP user by email (case-insensitive).
|
|
217
|
+
|
|
218
|
+
Note: Both sides of the comparison are lowercased to ensure consistent
|
|
219
|
+
matching regardless of what sanitize_email() does to the input.
|
|
220
|
+
"""
|
|
221
|
+
return cast(
|
|
222
|
+
models.User | None,
|
|
223
|
+
await session.scalar(
|
|
224
|
+
select(models.User)
|
|
225
|
+
.where(models.User.oauth2_client_id == LDAP_CLIENT_ID_MARKER)
|
|
226
|
+
.where(func.lower(models.User.email) == email.lower())
|
|
227
|
+
.options(joinedload(models.User.role))
|
|
228
|
+
),
|
|
229
|
+
)
|