arize-phoenix 11.23.1__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-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +61 -36
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/RECORD +212 -162
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
- {arize_phoenix-11.23.1.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 +2 -1
- phoenix/auth.py +27 -2
- phoenix/config.py +1594 -81
- phoenix/db/README.md +546 -28
- phoenix/db/bulk_inserter.py +119 -116
- phoenix/db/engines.py +140 -33
- phoenix/db/facilitator.py +22 -1
- phoenix/db/helpers.py +818 -65
- phoenix/db/iam_auth.py +64 -0
- phoenix/db/insertion/dataset.py +133 -1
- phoenix/db/insertion/document_annotation.py +9 -6
- phoenix/db/insertion/evaluation.py +2 -3
- phoenix/db/insertion/helpers.py +2 -2
- phoenix/db/insertion/session_annotation.py +176 -0
- phoenix/db/insertion/span_annotation.py +3 -4
- phoenix/db/insertion/trace_annotation.py +3 -4
- phoenix/db/insertion/types.py +41 -18
- 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/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 +364 -56
- phoenix/db/pg_config.py +10 -0
- phoenix/db/types/trace_retention.py +7 -6
- phoenix/experiments/functions.py +69 -19
- phoenix/inferences/inferences.py +1 -2
- phoenix/server/api/auth.py +9 -0
- phoenix/server/api/auth_messages.py +46 -0
- phoenix/server/api/context.py +60 -0
- phoenix/server/api/dataloaders/__init__.py +36 -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/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_summary_by_experiment_repeated_run_group.py +64 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_project.py +28 -14
- phoenix/server/api/dataloaders/span_costs.py +3 -9
- 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/exceptions.py +5 -1
- phoenix/server/api/helpers/playground_clients.py +263 -83
- phoenix/server/api/helpers/playground_spans.py +2 -1
- phoenix/server/api/helpers/playground_users.py +26 -0
- phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
- phoenix/server/api/helpers/prompts/models.py +61 -19
- phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
- phoenix/server/api/input_types/ChatCompletionInput.py +3 -0
- phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
- phoenix/server/api/input_types/DatasetFilter.py +5 -2
- phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
- phoenix/server/api/input_types/GenerativeModelInput.py +3 -0
- phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
- phoenix/server/api/input_types/PromptVersionInput.py +47 -1
- phoenix/server/api/input_types/SpanSort.py +3 -2
- phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
- phoenix/server/api/input_types/UserRoleInput.py +1 -0
- phoenix/server/api/mutations/__init__.py +8 -0
- phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
- phoenix/server/api/mutations/api_key_mutations.py +15 -20
- phoenix/server/api/mutations/chat_mutations.py +106 -37
- 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 +11 -9
- phoenix/server/api/mutations/project_mutations.py +4 -4
- 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 +13 -8
- phoenix/server/api/mutations/trace_mutations.py +3 -3
- phoenix/server/api/mutations/user_mutations.py +55 -26
- phoenix/server/api/queries.py +501 -617
- phoenix/server/api/routers/__init__.py +2 -2
- phoenix/server/api/routers/auth.py +141 -87
- phoenix/server/api/routers/ldap.py +229 -0
- phoenix/server/api/routers/oauth2.py +349 -101
- phoenix/server/api/routers/v1/__init__.py +22 -4
- phoenix/server/api/routers/v1/annotation_configs.py +19 -30
- phoenix/server/api/routers/v1/annotations.py +455 -13
- phoenix/server/api/routers/v1/datasets.py +355 -68
- phoenix/server/api/routers/v1/documents.py +142 -0
- phoenix/server/api/routers/v1/evaluations.py +20 -28
- phoenix/server/api/routers/v1/experiment_evaluations.py +16 -6
- phoenix/server/api/routers/v1/experiment_runs.py +335 -59
- phoenix/server/api/routers/v1/experiments.py +475 -47
- phoenix/server/api/routers/v1/projects.py +16 -50
- phoenix/server/api/routers/v1/prompts.py +50 -39
- phoenix/server/api/routers/v1/sessions.py +108 -0
- phoenix/server/api/routers/v1/spans.py +156 -96
- phoenix/server/api/routers/v1/traces.py +51 -77
- phoenix/server/api/routers/v1/users.py +64 -24
- phoenix/server/api/routers/v1/utils.py +3 -7
- phoenix/server/api/subscriptions.py +257 -93
- 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/Dataset.py +199 -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 +215 -68
- phoenix/server/api/types/ExperimentComparison.py +3 -9
- phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
- phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
- phoenix/server/api/types/ExperimentRun.py +120 -70
- phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
- phoenix/server/api/types/GenerativeModel.py +95 -42
- phoenix/server/api/types/GenerativeProvider.py +1 -1
- phoenix/server/api/types/ModelInterface.py +7 -2
- phoenix/server/api/types/PlaygroundModel.py +12 -2
- phoenix/server/api/types/Project.py +218 -185
- phoenix/server/api/types/ProjectSession.py +146 -29
- 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/Span.py +130 -123
- phoenix/server/api/types/SpanAnnotation.py +189 -42
- phoenix/server/api/types/SystemApiKey.py +65 -1
- phoenix/server/api/types/Trace.py +184 -53
- phoenix/server/api/types/TraceAnnotation.py +149 -50
- phoenix/server/api/types/User.py +128 -33
- 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 +154 -36
- phoenix/server/authorization.py +5 -4
- phoenix/server/bearer_auth.py +13 -5
- phoenix/server/cost_tracking/cost_model_lookup.py +42 -14
- phoenix/server/cost_tracking/model_cost_manifest.json +1085 -194
- phoenix/server/daemons/generative_model_store.py +61 -9
- phoenix/server/daemons/span_cost_calculator.py +10 -8
- phoenix/server/dml_event.py +13 -0
- phoenix/server/email/sender.py +29 -2
- phoenix/server/grpc_server.py +9 -9
- phoenix/server/jwt_store.py +8 -6
- phoenix/server/ldap.py +1449 -0
- phoenix/server/main.py +9 -3
- phoenix/server/oauth2.py +330 -12
- phoenix/server/prometheus.py +43 -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 +51 -53
- phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
- phoenix/server/static/assets/{index-BPCwGQr8.js → index-CTQoemZv.js} +42 -35
- 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-Bw30oz1A.js → vendor-recharts-V9cwpXsm.js} +7 -7
- phoenix/server/static/assets/{vendor-shiki-DZajAPeq.js → vendor-shiki-Do--csgv.js} +1 -1
- phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
- phoenix/server/templates/index.html +7 -1
- phoenix/server/thread_server.py +1 -2
- phoenix/server/utils.py +74 -0
- phoenix/session/client.py +55 -1
- phoenix/session/data_extractor.py +5 -0
- phoenix/session/evaluation.py +8 -4
- phoenix/session/session.py +44 -8
- phoenix/settings.py +2 -0
- phoenix/trace/attributes.py +80 -13
- phoenix/trace/dsl/query.py +2 -0
- phoenix/trace/projects.py +5 -0
- phoenix/utilities/template_formatters.py +1 -1
- phoenix/version.py +1 -1
- phoenix/server/api/types/Evaluation.py +0 -39
- phoenix/server/static/assets/components-D0DWAf0l.js +0 -5650
- phoenix/server/static/assets/pages-Creyamao.js +0 -8612
- phoenix/server/static/assets/vendor-CU36oj8y.js +0 -905
- phoenix/server/static/assets/vendor-CqDb5u4o.css +0 -1
- phoenix/server/static/assets/vendor-arizeai-Ctgw0e1G.js +0 -168
- phoenix/server/static/assets/vendor-codemirror-Cojjzqb9.js +0 -25
- phoenix/server/static/assets/vendor-three-BLWp5bic.js +0 -2998
- phoenix/utilities/deprecation.py +0 -31
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/LICENSE +0 -0
phoenix/config.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import logging
|
|
4
5
|
import os
|
|
5
6
|
import re
|
|
@@ -9,12 +10,25 @@ from datetime import timedelta
|
|
|
9
10
|
from enum import Enum
|
|
10
11
|
from importlib.metadata import version
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from typing import
|
|
13
|
+
from typing import (
|
|
14
|
+
TYPE_CHECKING,
|
|
15
|
+
Any,
|
|
16
|
+
Literal,
|
|
17
|
+
NamedTuple,
|
|
18
|
+
Optional,
|
|
19
|
+
TypedDict,
|
|
20
|
+
Union,
|
|
21
|
+
cast,
|
|
22
|
+
overload,
|
|
23
|
+
)
|
|
13
24
|
from urllib.parse import quote, urljoin, urlparse
|
|
14
25
|
|
|
15
26
|
import wrapt
|
|
16
27
|
from email_validator import EmailNotValidError, validate_email
|
|
28
|
+
from ldap3.core.exceptions import LDAPInvalidDnError
|
|
29
|
+
from ldap3.utils.dn import parse_dn
|
|
17
30
|
from starlette.datastructures import URL, Secret
|
|
31
|
+
from typing_extensions import TypeAlias, get_args
|
|
18
32
|
|
|
19
33
|
from phoenix.utilities.logging import log_a_list
|
|
20
34
|
from phoenix.utilities.re import parse_env_headers
|
|
@@ -22,6 +36,13 @@ from phoenix.utilities.re import parse_env_headers
|
|
|
22
36
|
if TYPE_CHECKING:
|
|
23
37
|
from phoenix.server.oauth2 import OAuth2Clients
|
|
24
38
|
|
|
39
|
+
# Assignable roles (SYSTEM is internal-only and not included)
|
|
40
|
+
AssignableUserRoleName: TypeAlias = Literal["ADMIN", "MEMBER", "VIEWER"]
|
|
41
|
+
|
|
42
|
+
# Tuple of valid OAuth2 roles for validation
|
|
43
|
+
_VALID_ROLES: tuple[str, ...] = get_args(AssignableUserRoleName)
|
|
44
|
+
|
|
45
|
+
|
|
25
46
|
logger = logging.getLogger(__name__)
|
|
26
47
|
|
|
27
48
|
ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
@@ -57,6 +78,17 @@ ENV_PHOENIX_FULLSTORY_ORG = "PHOENIX_FULLSTORY_ORG"
|
|
|
57
78
|
The FullStory organization ID for web analytics tracking. When set, FullStory tracking
|
|
58
79
|
will be enabled in the Phoenix web interface.
|
|
59
80
|
"""
|
|
81
|
+
ENV_PHOENIX_SCARF_SH_PIXEL_ID = "PHOENIX_SCARF_SH_PIXEL_ID"
|
|
82
|
+
"""
|
|
83
|
+
The Scarf.sh pixel ID for web analytics tracking. When set, Scarf.sh tracking
|
|
84
|
+
will be enabled in the Phoenix web interface.
|
|
85
|
+
"""
|
|
86
|
+
ENV_PHOENIX_TELEMETRY_ENABLED = "PHOENIX_TELEMETRY_ENABLED"
|
|
87
|
+
"""
|
|
88
|
+
Master toggle for telemetry pixels (FullStory and Scarf.sh).
|
|
89
|
+
When set to False, disables both FullStory and Scarf.sh tracking regardless of their
|
|
90
|
+
individual environment variable settings. Defaults to True.
|
|
91
|
+
"""
|
|
60
92
|
ENV_PHOENIX_ALLOW_EXTERNAL_RESOURCES = "PHOENIX_ALLOW_EXTERNAL_RESOURCES"
|
|
61
93
|
"""
|
|
62
94
|
Allows calls to external resources, like Google Fonts in the web interface
|
|
@@ -91,16 +123,44 @@ Used with PHOENIX_POSTGRES_HOST to specify the port to use for the PostgreSQL da
|
|
|
91
123
|
ENV_PHOENIX_POSTGRES_USER = "PHOENIX_POSTGRES_USER"
|
|
92
124
|
"""
|
|
93
125
|
Used with PHOENIX_POSTGRES_HOST to specify the user to use for the PostgreSQL database (required).
|
|
126
|
+
|
|
127
|
+
When using AWS RDS IAM authentication (PHOENIX_POSTGRES_USE_AWS_IAM_AUTH=true), this should be
|
|
128
|
+
set to the IAM-enabled database username configured in your RDS/Aurora instance.
|
|
94
129
|
"""
|
|
95
130
|
ENV_PHOENIX_POSTGRES_PASSWORD = "PHOENIX_POSTGRES_PASSWORD"
|
|
96
131
|
"""
|
|
97
132
|
Used with PHOENIX_POSTGRES_HOST to specify the password to use for the PostgreSQL database
|
|
98
|
-
(required).
|
|
133
|
+
(required, unless PHOENIX_POSTGRES_USE_AWS_IAM_AUTH is enabled).
|
|
134
|
+
|
|
135
|
+
When using AWS RDS IAM authentication (PHOENIX_POSTGRES_USE_AWS_IAM_AUTH=true), this password
|
|
136
|
+
is NOT used. Instead, authentication tokens are generated dynamically using AWS IAM credentials.
|
|
99
137
|
"""
|
|
100
138
|
ENV_PHOENIX_POSTGRES_DB = "PHOENIX_POSTGRES_DB"
|
|
101
139
|
"""
|
|
102
140
|
Used with PHOENIX_POSTGRES_HOST to specify the database to use for the PostgreSQL database.
|
|
103
141
|
"""
|
|
142
|
+
ENV_PHOENIX_POSTGRES_USE_AWS_IAM_AUTH = "PHOENIX_POSTGRES_USE_AWS_IAM_AUTH"
|
|
143
|
+
"""
|
|
144
|
+
Enable AWS RDS IAM database authentication. When enabled, Phoenix will use AWS IAM credentials
|
|
145
|
+
to generate short-lived authentication tokens instead of using a static password.
|
|
146
|
+
|
|
147
|
+
This requires:
|
|
148
|
+
- boto3 to be installed: pip install 'arize-phoenix[aws]'
|
|
149
|
+
- AWS credentials configured (via environment, ~/.aws/credentials, or IAM role)
|
|
150
|
+
- AWS region configured via standard AWS methods
|
|
151
|
+
- The database user to be configured for IAM authentication in RDS/Aurora
|
|
152
|
+
- SSL to be enabled (required by AWS RDS IAM auth)
|
|
153
|
+
|
|
154
|
+
When enabled, PHOENIX_POSTGRES_PASSWORD should NOT be set.
|
|
155
|
+
"""
|
|
156
|
+
ENV_PHOENIX_POSTGRES_AWS_IAM_TOKEN_LIFETIME_SECONDS = (
|
|
157
|
+
"PHOENIX_POSTGRES_AWS_IAM_TOKEN_LIFETIME_SECONDS"
|
|
158
|
+
)
|
|
159
|
+
"""
|
|
160
|
+
Token lifetime in seconds for connection pool recycling when using AWS RDS IAM authentication.
|
|
161
|
+
AWS RDS auth tokens are valid for 15 minutes. This should be set slightly lower to ensure
|
|
162
|
+
tokens are refreshed before expiration. Defaults to 840 seconds (14 minutes).
|
|
163
|
+
"""
|
|
104
164
|
ENV_PHOENIX_SQL_DATABASE_SCHEMA = "PHOENIX_SQL_DATABASE_SCHEMA"
|
|
105
165
|
"""
|
|
106
166
|
The schema to use for the PostgresSQL database. (This is ignored for SQLite.)
|
|
@@ -134,6 +194,25 @@ ENV_PHOENIX_ENABLE_PROMETHEUS = "PHOENIX_ENABLE_PROMETHEUS"
|
|
|
134
194
|
"""
|
|
135
195
|
Whether to enable Prometheus. Defaults to false.
|
|
136
196
|
"""
|
|
197
|
+
ENV_PHOENIX_MAX_SPANS_QUEUE_SIZE = "PHOENIX_MAX_SPANS_QUEUE_SIZE"
|
|
198
|
+
"""
|
|
199
|
+
The maximum number of spans to hold in the processing queue before rejecting new requests.
|
|
200
|
+
|
|
201
|
+
This is a heuristic to prevent memory issues when spans accumulate faster than they can be
|
|
202
|
+
written to the database. When this limit is reached, new incoming requests will be rejected
|
|
203
|
+
to protect system memory.
|
|
204
|
+
|
|
205
|
+
Note: The actual queue size may exceed this limit due to batch processing. Requests are
|
|
206
|
+
accepted or rejected before spans are deserialized, but a single accepted request may
|
|
207
|
+
contain multiple spans. This behavior is intentional to balance memory protection with
|
|
208
|
+
processing efficiency.
|
|
209
|
+
|
|
210
|
+
Memory usage: If an average span takes ~50KiB of memory, then 20,000 spans would use ~1GiB
|
|
211
|
+
of memory. Adjust this value based on your system's available memory and expected database
|
|
212
|
+
throughput.
|
|
213
|
+
|
|
214
|
+
Defaults to 20000.
|
|
215
|
+
"""
|
|
137
216
|
ENV_LOGGING_MODE = "PHOENIX_LOGGING_MODE"
|
|
138
217
|
"""
|
|
139
218
|
The logging mode (either 'default' or 'structured').
|
|
@@ -172,6 +251,11 @@ ENV_PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT = (
|
|
|
172
251
|
"PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT"
|
|
173
252
|
)
|
|
174
253
|
|
|
254
|
+
ENV_PHOENIX_MASK_INTERNAL_SERVER_ERRORS = "PHOENIX_MASK_INTERNAL_SERVER_ERRORS"
|
|
255
|
+
"""
|
|
256
|
+
Whether to mask internal server errors from the GraphQL and REST APIs. Defaults to true.
|
|
257
|
+
"""
|
|
258
|
+
|
|
175
259
|
# Authentication settings
|
|
176
260
|
ENV_PHOENIX_ENABLE_AUTH = "PHOENIX_ENABLE_AUTH"
|
|
177
261
|
ENV_PHOENIX_DISABLE_BASIC_AUTH = "PHOENIX_DISABLE_BASIC_AUTH"
|
|
@@ -222,15 +306,264 @@ password reset emails. If this variable is left unspecified or contains no origi
|
|
|
222
306
|
protection will not be enabled. In such cases, when a request includes `origin` or `referer`
|
|
223
307
|
headers, those values will not be validated.
|
|
224
308
|
"""
|
|
309
|
+
|
|
310
|
+
# LDAP authentication settings
|
|
311
|
+
ENV_PHOENIX_LDAP_HOST = "PHOENIX_LDAP_HOST"
|
|
312
|
+
"""
|
|
313
|
+
LDAP server hosts (comma-separated for multiple servers with failover).
|
|
314
|
+
Example: "ldap.corp.com" or "dc1.corp.com,dc2.corp.com,dc3.corp.com"
|
|
315
|
+
|
|
316
|
+
Multi-server failover behavior:
|
|
317
|
+
- Connection errors (server unreachable, timeout): Automatically tries the next server
|
|
318
|
+
- User not found: Returns immediately (no failover to other servers)
|
|
319
|
+
- Invalid password: Returns immediately (no failover to other servers)
|
|
320
|
+
|
|
321
|
+
This assumes all servers are replicas with identical user sets (the common HA pattern).
|
|
322
|
+
Multi-domain/forest configurations where different users exist on different servers
|
|
323
|
+
are NOT supported.
|
|
324
|
+
"""
|
|
325
|
+
ENV_PHOENIX_LDAP_PORT = "PHOENIX_LDAP_PORT"
|
|
326
|
+
"""
|
|
327
|
+
LDAP server port. Defaults to 389 for StartTLS, 636 for LDAPS.
|
|
328
|
+
"""
|
|
329
|
+
ENV_PHOENIX_LDAP_TLS_MODE = "PHOENIX_LDAP_TLS_MODE"
|
|
330
|
+
"""
|
|
331
|
+
TLS connection mode. Defaults to "starttls". Options:
|
|
332
|
+
- "starttls": Upgrade from plaintext to TLS on port 389 (recommended)
|
|
333
|
+
- "ldaps": TLS from connection start on port 636
|
|
334
|
+
- "none": No encryption (testing only, credentials sent in plaintext)
|
|
335
|
+
"""
|
|
336
|
+
ENV_PHOENIX_LDAP_TLS_VERIFY = "PHOENIX_LDAP_TLS_VERIFY"
|
|
337
|
+
"""
|
|
338
|
+
Verify TLS certificates. Defaults to true. Should always be true in production.
|
|
339
|
+
"""
|
|
340
|
+
ENV_PHOENIX_LDAP_TLS_CA_CERT_FILE = "PHOENIX_LDAP_TLS_CA_CERT_FILE"
|
|
341
|
+
"""
|
|
342
|
+
Path to custom CA certificate file (PEM format) for TLS verification. Optional.
|
|
343
|
+
Use when LDAP server uses a private/internal CA not in the system trust store.
|
|
344
|
+
Example: "/etc/ssl/certs/internal-ca.pem"
|
|
345
|
+
"""
|
|
346
|
+
ENV_PHOENIX_LDAP_TLS_CLIENT_CERT_FILE = "PHOENIX_LDAP_TLS_CLIENT_CERT_FILE"
|
|
347
|
+
"""
|
|
348
|
+
Path to client certificate file (PEM format) for mutual TLS authentication. Optional.
|
|
349
|
+
Requires PHOENIX_LDAP_TLS_CLIENT_KEY_FILE to also be set.
|
|
350
|
+
Example: "/etc/ssl/certs/phoenix-client.crt"
|
|
351
|
+
"""
|
|
352
|
+
ENV_PHOENIX_LDAP_TLS_CLIENT_KEY_FILE = "PHOENIX_LDAP_TLS_CLIENT_KEY_FILE"
|
|
353
|
+
"""
|
|
354
|
+
Path to client private key file (PEM format) for mutual TLS authentication. Optional.
|
|
355
|
+
Requires PHOENIX_LDAP_TLS_CLIENT_CERT_FILE to also be set.
|
|
356
|
+
Example: "/etc/ssl/private/phoenix-client.key"
|
|
357
|
+
"""
|
|
358
|
+
ENV_PHOENIX_LDAP_BIND_DN = "PHOENIX_LDAP_BIND_DN"
|
|
359
|
+
"""
|
|
360
|
+
Service account DN for binding to LDAP server. Optional for direct bind.
|
|
361
|
+
Example: "CN=svc-phoenix,OU=Service Accounts,DC=corp,DC=com"
|
|
362
|
+
"""
|
|
363
|
+
ENV_PHOENIX_LDAP_BIND_PASSWORD = "PHOENIX_LDAP_BIND_PASSWORD"
|
|
364
|
+
"""
|
|
365
|
+
Service account password for binding to LDAP server.
|
|
366
|
+
Required if BIND_DN is set. Should be stored securely (e.g., Kubernetes Secret,
|
|
367
|
+
environment variable from secrets manager). Avoid hardcoding in configuration files.
|
|
368
|
+
"""
|
|
369
|
+
ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS = "PHOENIX_LDAP_USER_SEARCH_BASE_DNS"
|
|
370
|
+
"""
|
|
371
|
+
JSON array of base DNs for user searches. Searches are performed in order until a user is found.
|
|
372
|
+
Example: '["OU=Users,DC=corp,DC=com"]'
|
|
373
|
+
Multiple: '["OU=Employees,DC=corp,DC=com", "OU=Contractors,DC=corp,DC=com"]'
|
|
374
|
+
"""
|
|
375
|
+
ENV_PHOENIX_LDAP_USER_SEARCH_FILTER = "PHOENIX_LDAP_USER_SEARCH_FILTER"
|
|
376
|
+
"""
|
|
377
|
+
LDAP filter for finding users. Use %s as placeholder for username.
|
|
378
|
+
Example: "(&(objectClass=user)(sAMAccountName=%s))"
|
|
379
|
+
"""
|
|
380
|
+
ENV_PHOENIX_LDAP_ATTR_EMAIL = "PHOENIX_LDAP_ATTR_EMAIL"
|
|
381
|
+
"""
|
|
382
|
+
LDAP attribute containing user's email address. Required.
|
|
383
|
+
|
|
384
|
+
Set to the attribute name (e.g., "mail") if your LDAP directory has email addresses.
|
|
385
|
+
The attribute must be present in LDAP or login fails.
|
|
386
|
+
|
|
387
|
+
Set to "null" if your LDAP directory does not have email addresses. When set to "null":
|
|
388
|
+
- PHOENIX_LDAP_ATTR_UNIQUE_ID is required (users are identified by unique_id instead)
|
|
389
|
+
- PHOENIX_LDAP_ALLOW_SIGN_UP must be true (users are auto-provisioned on first login)
|
|
390
|
+
- PHOENIX_ADMINS is not supported (use PHOENIX_LDAP_GROUP_ROLE_MAPPINGS instead)
|
|
391
|
+
- Users will appear in Phoenix without email addresses
|
|
392
|
+
|
|
393
|
+
https://www.rfc-editor.org/rfc/rfc2798#section-9.1.3
|
|
394
|
+
"""
|
|
395
|
+
ENV_PHOENIX_LDAP_ATTR_UNIQUE_ID = "PHOENIX_LDAP_ATTR_UNIQUE_ID"
|
|
396
|
+
"""
|
|
397
|
+
LDAP attribute containing an immutable unique identifier.
|
|
398
|
+
|
|
399
|
+
REQUIRED when PHOENIX_LDAP_ATTR_EMAIL is "null" (users are identified by this ID
|
|
400
|
+
instead of email).
|
|
401
|
+
|
|
402
|
+
Also recommended if you expect user emails to change (company rebranding, M&A,
|
|
403
|
+
frequent name changes) or have compliance requirements for immutable user tracking.
|
|
404
|
+
For most organizations with email in LDAP, the default email-based identification
|
|
405
|
+
is sufficient.
|
|
406
|
+
|
|
407
|
+
When set, this attribute is used as the primary identifier, allowing users
|
|
408
|
+
to survive email changes without creating duplicate accounts.
|
|
409
|
+
|
|
410
|
+
Supported attributes (UUID-based only):
|
|
411
|
+
- Active Directory: "objectGUID" (16-byte binary UUID)
|
|
412
|
+
- OpenLDAP: "entryUUID" (RFC 4530, string UUID)
|
|
413
|
+
- 389 Directory Server: "nsUniqueId" (string UUID)
|
|
414
|
+
|
|
415
|
+
IMPORTANT: Only standard UUID-based attributes are supported. Custom attributes
|
|
416
|
+
containing 16-character string IDs (e.g., "EMP12345ABCD6789") are NOT supported
|
|
417
|
+
and will be incorrectly converted.
|
|
418
|
+
|
|
419
|
+
When not set (default), email is used as the identifier. Both modes handle
|
|
420
|
+
DN changes (OU moves, renames). The only difference is email change handling.
|
|
421
|
+
"""
|
|
422
|
+
ENV_PHOENIX_LDAP_ATTR_DISPLAY_NAME = "PHOENIX_LDAP_ATTR_DISPLAY_NAME"
|
|
423
|
+
"""
|
|
424
|
+
LDAP attribute containing user's display name. Defaults to "displayName".
|
|
425
|
+
https://www.rfc-editor.org/rfc/rfc2798.html#section-2.3
|
|
426
|
+
"""
|
|
427
|
+
ENV_PHOENIX_LDAP_ATTR_MEMBER_OF = "PHOENIX_LDAP_ATTR_MEMBER_OF"
|
|
428
|
+
"""
|
|
429
|
+
LDAP attribute containing group memberships. Defaults to "memberOf".
|
|
430
|
+
Used for Active Directory and OpenLDAP with memberOf overlay.
|
|
431
|
+
This attribute is only used when GROUP_SEARCH_FILTER is not set.
|
|
432
|
+
"""
|
|
433
|
+
ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS = "PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS"
|
|
434
|
+
"""
|
|
435
|
+
JSON array of base DNs for group searches (for POSIX/OpenLDAP).
|
|
436
|
+
Required when using GROUP_SEARCH_FILTER.
|
|
437
|
+
Example: '["ou=groups,dc=example,dc=com"]'
|
|
438
|
+
Multiple: '["ou=groups,dc=corp,dc=com", "ou=teams,dc=corp,dc=com"]'
|
|
439
|
+
"""
|
|
440
|
+
ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER = "PHOENIX_LDAP_GROUP_SEARCH_FILTER"
|
|
441
|
+
"""
|
|
442
|
+
LDAP filter for finding groups containing a user. Use %s as placeholder for user identifier.
|
|
443
|
+
|
|
444
|
+
Two Group Resolution Modes
|
|
445
|
+
--------------------------
|
|
446
|
+
This setting determines how group membership is resolved:
|
|
447
|
+
|
|
448
|
+
1. AD Mode (this setting NOT set - RECOMMENDED for Active Directory):
|
|
449
|
+
Reads the memberOf attribute directly from the user entry.
|
|
450
|
+
Active Directory automatically populates this attribute.
|
|
451
|
+
Configure PHOENIX_LDAP_ATTR_MEMBER_OF if the attribute name differs.
|
|
452
|
+
|
|
453
|
+
2. Search Mode (this setting IS set):
|
|
454
|
+
Searches for groups that contain the user in PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS.
|
|
455
|
+
Required for POSIX groups (posixGroup) or when memberOf is unavailable.
|
|
456
|
+
|
|
457
|
+
Placeholder Substitution
|
|
458
|
+
------------------------
|
|
459
|
+
The %s placeholder is replaced with a user identifier. What value is used depends on
|
|
460
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR:
|
|
461
|
+
- If not set (default): Uses the login username directly
|
|
462
|
+
- If set: Uses that attribute's value from the user's LDAP entry
|
|
463
|
+
|
|
464
|
+
See PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR for detailed examples of:
|
|
465
|
+
- POSIX groups (memberUid contains usernames)
|
|
466
|
+
- groupOfNames (member contains full DNs)
|
|
467
|
+
|
|
468
|
+
Example POSIX configuration:
|
|
469
|
+
PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS=["ou=groups,dc=example,dc=com"]
|
|
470
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER=(&(objectClass=posixGroup)(memberUid=%s))
|
|
471
|
+
# PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR not needed - uses login username
|
|
472
|
+
"""
|
|
473
|
+
ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR = "PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR"
|
|
474
|
+
"""
|
|
475
|
+
LDAP attribute from the user entry to substitute for %s in GROUP_SEARCH_FILTER.
|
|
476
|
+
|
|
477
|
+
When set, reads the specified attribute from the user's LDAP entry and uses its value.
|
|
478
|
+
When not set (default), uses the login username directly.
|
|
479
|
+
|
|
480
|
+
Understanding Group Membership Attributes
|
|
481
|
+
-----------------------------------------
|
|
482
|
+
Different LDAP group types store membership differently:
|
|
483
|
+
|
|
484
|
+
1. POSIX groups (posixGroup objectClass):
|
|
485
|
+
- Use "memberUid" attribute which contains **usernames** (e.g., "jdoe")
|
|
486
|
+
- Filter: (&(objectClass=posixGroup)(memberUid=%s))
|
|
487
|
+
- Use: PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR not set (uses login username)
|
|
488
|
+
or set to "uid" if login username differs from uid attribute
|
|
489
|
+
|
|
490
|
+
2. groupOfNames/groupOfUniqueNames (RFC 4519):
|
|
491
|
+
- Use "member"/"uniqueMember" which contains **full DNs**
|
|
492
|
+
(e.g., "uid=jdoe,ou=users,dc=example,dc=com")
|
|
493
|
+
- Filter: (&(objectClass=groupOfNames)(member=%s))
|
|
494
|
+
- Use: PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR=distinguishedName (AD only)
|
|
495
|
+
- Note: OpenLDAP does not expose DN as an attribute. For groupOfNames with
|
|
496
|
+
OpenLDAP, consider using memberOf overlay instead (AD mode).
|
|
497
|
+
|
|
498
|
+
3. Active Directory groups:
|
|
499
|
+
- RECOMMENDED: Use AD mode (memberOf attribute) instead of group search.
|
|
500
|
+
AD automatically populates memberOf on user entries.
|
|
501
|
+
Simply leave PHOENIX_LDAP_GROUP_SEARCH_FILTER unset.
|
|
502
|
+
- If you must use group search: AD returns "distinguishedName" as an attribute,
|
|
503
|
+
so PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR=distinguishedName works.
|
|
504
|
+
|
|
505
|
+
Common values:
|
|
506
|
+
- Not set (default): Uses the login username directly
|
|
507
|
+
- "uid": Explicitly use uid attribute (same as default for most setups)
|
|
508
|
+
- "distinguishedName": Full DN (Active Directory only)
|
|
509
|
+
- "sAMAccountName": Windows login name (Active Directory)
|
|
510
|
+
|
|
511
|
+
Example configurations:
|
|
512
|
+
|
|
513
|
+
POSIX groups with OpenLDAP (memberUid contains usernames):
|
|
514
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER=(&(objectClass=posixGroup)(memberUid=%s))
|
|
515
|
+
# No PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR needed - uses login username
|
|
516
|
+
|
|
517
|
+
POSIX groups when login differs from uid:
|
|
518
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER=(&(objectClass=posixGroup)(memberUid=%s))
|
|
519
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR=uid
|
|
520
|
+
|
|
521
|
+
Active Directory with group search (not recommended - use memberOf instead):
|
|
522
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER=(member:1.2.840.113556.1.4.1941:=%s)
|
|
523
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR=distinguishedName
|
|
524
|
+
"""
|
|
525
|
+
ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS = "PHOENIX_LDAP_GROUP_ROLE_MAPPINGS"
|
|
526
|
+
"""
|
|
527
|
+
JSON array mapping LDAP groups to Phoenix roles.
|
|
528
|
+
Example: '[{"group_dn": "CN=Phoenix Admins,OU=Groups,DC=corp,DC=com", "role": "ADMIN"}]'
|
|
529
|
+
Supported role values: "ADMIN", "MEMBER", "VIEWER" (case-insensitive).
|
|
530
|
+
Special group_dn value "*" matches all users (wildcard).
|
|
531
|
+
Order matters: first matching group_dn in the array determines the role.
|
|
532
|
+
"""
|
|
533
|
+
ENV_PHOENIX_LDAP_ALLOW_SIGN_UP = "PHOENIX_LDAP_ALLOW_SIGN_UP"
|
|
534
|
+
"""
|
|
535
|
+
Allow automatic user creation on first LDAP login. Defaults to "true".
|
|
536
|
+
Set to "false" to require pre-provisioned users (created via PHOENIX_ADMINS
|
|
537
|
+
env var or the application's user management UI before first login).
|
|
538
|
+
Pre-provisioned users are matched by email on first LDAP login.
|
|
539
|
+
|
|
540
|
+
MUST be "true" when PHOENIX_LDAP_ATTR_EMAIL is "null", since pre-provisioning
|
|
541
|
+
by email is not possible without email addresses in LDAP.
|
|
542
|
+
"""
|
|
543
|
+
|
|
225
544
|
ENV_PHOENIX_ADMINS = "PHOENIX_ADMINS"
|
|
226
545
|
"""
|
|
227
546
|
A semicolon-separated list of username and email address pairs to create as admin users on startup.
|
|
228
547
|
The format is `username=email`, e.g., `John Doe=john@example.com;Doe, Jane=jane@example.com`.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
548
|
+
|
|
549
|
+
User Type Selection:
|
|
550
|
+
The type of user created depends on the authentication configuration:
|
|
551
|
+
- If basic auth is enabled (PHOENIX_DISABLE_BASIC_AUTH=false, default): Creates a LocalUser
|
|
552
|
+
with a randomly generated password that must be reset on first login.
|
|
553
|
+
- If basic auth is disabled AND LDAP is configured: Creates an LDAPUser that will be matched
|
|
554
|
+
by email on first LDAP login.
|
|
555
|
+
- If basic auth is disabled AND only OAuth2 is configured: Creates an OAuth2User that will be
|
|
556
|
+
matched by email on first OAuth2 login.
|
|
557
|
+
|
|
558
|
+
Notes:
|
|
559
|
+
- The application will not start if this environment variable is set but cannot be parsed or
|
|
560
|
+
contains invalid emails.
|
|
561
|
+
- If the username or email address already exists in the database, the user record will not be
|
|
562
|
+
modified, e.g., changed from non-admin to admin.
|
|
563
|
+
- Changing this environment variable for the next startup will not undo any records created in
|
|
564
|
+
previous startups.
|
|
565
|
+
- NOT supported when PHOENIX_LDAP_ATTR_EMAIL is "null" (use PHOENIX_LDAP_GROUP_ROLE_MAPPINGS
|
|
566
|
+
to assign admin roles instead when LDAP doesn't have email addresses).
|
|
234
567
|
"""
|
|
235
568
|
ENV_PHOENIX_ROOT_URL = "PHOENIX_ROOT_URL"
|
|
236
569
|
"""
|
|
@@ -780,6 +1113,7 @@ class AuthSettings(NamedTuple):
|
|
|
780
1113
|
phoenix_secret: Secret
|
|
781
1114
|
phoenix_admin_secret: Secret
|
|
782
1115
|
oauth2_clients: OAuth2Clients
|
|
1116
|
+
ldap_config: Optional[LDAPConfig]
|
|
783
1117
|
|
|
784
1118
|
|
|
785
1119
|
def get_env_auth_settings() -> AuthSettings:
|
|
@@ -798,9 +1132,13 @@ def get_env_auth_settings() -> AuthSettings:
|
|
|
798
1132
|
from phoenix.server.oauth2 import OAuth2Clients
|
|
799
1133
|
|
|
800
1134
|
oauth2_clients = OAuth2Clients.from_configs(get_env_oauth2_settings())
|
|
801
|
-
|
|
1135
|
+
ldap_config = LDAPConfig.from_env()
|
|
1136
|
+
|
|
1137
|
+
if enable_auth and disable_basic_auth and not oauth2_clients and not ldap_config:
|
|
802
1138
|
raise ValueError(
|
|
803
|
-
"
|
|
1139
|
+
f"{ENV_PHOENIX_DISABLE_BASIC_AUTH} is set, but no alternative authentication methods "
|
|
1140
|
+
"are configured. Please configure at least one of: OAuth2 "
|
|
1141
|
+
f"(PHOENIX_OAUTH2_*) or LDAP ({ENV_PHOENIX_LDAP_HOST})."
|
|
804
1142
|
)
|
|
805
1143
|
return AuthSettings(
|
|
806
1144
|
enable_auth=enable_auth,
|
|
@@ -808,6 +1146,7 @@ def get_env_auth_settings() -> AuthSettings:
|
|
|
808
1146
|
phoenix_secret=phoenix_secret,
|
|
809
1147
|
phoenix_admin_secret=phoenix_admin_secret,
|
|
810
1148
|
oauth2_clients=oauth2_clients,
|
|
1149
|
+
ldap_config=ldap_config,
|
|
811
1150
|
)
|
|
812
1151
|
|
|
813
1152
|
|
|
@@ -942,90 +1281,1056 @@ def get_env_smtp_validate_certs() -> bool:
|
|
|
942
1281
|
return _bool_val(ENV_PHOENIX_SMTP_VALIDATE_CERTS, True)
|
|
943
1282
|
|
|
944
1283
|
|
|
1284
|
+
_ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = (
|
|
1285
|
+
"client_secret_basic",
|
|
1286
|
+
"client_secret_post",
|
|
1287
|
+
"none",
|
|
1288
|
+
)
|
|
1289
|
+
"""Allowed OAuth2 token endpoint authentication methods (OIDC Core §9)."""
|
|
1290
|
+
|
|
1291
|
+
|
|
945
1292
|
@dataclass(frozen=True)
|
|
946
1293
|
class OAuth2ClientConfig:
|
|
1294
|
+
"""Configuration for an OAuth2/OIDC identity provider."""
|
|
1295
|
+
|
|
1296
|
+
# Identity provider identification
|
|
947
1297
|
idp_name: str
|
|
948
1298
|
idp_display_name: str
|
|
1299
|
+
|
|
1300
|
+
# OAuth2 client credentials (RFC 6749 §2)
|
|
949
1301
|
client_id: str
|
|
950
|
-
client_secret:
|
|
1302
|
+
client_secret: Optional[
|
|
1303
|
+
str
|
|
1304
|
+
] # Optional when token_endpoint_auth_method is "none" (RFC 6749 §2.3.1)
|
|
951
1305
|
oidc_config_url: str
|
|
1306
|
+
|
|
1307
|
+
# Authentication behavior
|
|
952
1308
|
allow_sign_up: bool
|
|
953
1309
|
auto_login: bool
|
|
1310
|
+
use_pkce: bool # Proof Key for Code Exchange (RFC 7636)
|
|
1311
|
+
token_endpoint_auth_method: Optional[str] # OIDC Core §9
|
|
1312
|
+
|
|
1313
|
+
# Scopes and permissions (RFC 6749 §3.3: space-delimited)
|
|
1314
|
+
scopes: str
|
|
1315
|
+
|
|
1316
|
+
# Group-based access control
|
|
1317
|
+
groups_attribute_path: Optional[str]
|
|
1318
|
+
allowed_groups: list[str]
|
|
1319
|
+
|
|
1320
|
+
# Role mapping
|
|
1321
|
+
role_attribute_path: Optional[str]
|
|
1322
|
+
role_mapping: dict[str, AssignableUserRoleName]
|
|
1323
|
+
role_attribute_strict: bool
|
|
954
1324
|
|
|
955
1325
|
@classmethod
|
|
956
1326
|
def from_env(cls, idp_name: str) -> "OAuth2ClientConfig":
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
):
|
|
961
|
-
raise
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
)
|
|
969
|
-
):
|
|
970
|
-
raise ValueError(
|
|
971
|
-
f"A client secret must be set for the {idp_name} OAuth2 IDP "
|
|
972
|
-
f"via the {client_secret_env_var} environment variable"
|
|
973
|
-
)
|
|
974
|
-
if not (
|
|
975
|
-
oidc_config_url := (
|
|
976
|
-
getenv(
|
|
977
|
-
oidc_config_url_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_OIDC_CONFIG_URL",
|
|
1327
|
+
"""Load OAuth2 client configuration from environment variables for the given IDP name."""
|
|
1328
|
+
idp_prefix = f"PHOENIX_OAUTH2_{idp_name.upper()}"
|
|
1329
|
+
|
|
1330
|
+
def _get_required(suffix: str, description: str) -> str:
|
|
1331
|
+
"""Get a required environment variable or raise a descriptive error."""
|
|
1332
|
+
env_var = f"{idp_prefix}_{suffix}"
|
|
1333
|
+
value = getenv(env_var)
|
|
1334
|
+
if value is None or not value:
|
|
1335
|
+
raise ValueError(
|
|
1336
|
+
f"{description} must be set for the {idp_name} OAuth2 IDP "
|
|
1337
|
+
f"via the {env_var} environment variable"
|
|
978
1338
|
)
|
|
1339
|
+
return value
|
|
1340
|
+
|
|
1341
|
+
def _get_optional(suffix: str) -> Optional[str]:
|
|
1342
|
+
"""Get an optional environment variable."""
|
|
1343
|
+
return getenv(f"{idp_prefix}_{suffix}")
|
|
1344
|
+
|
|
1345
|
+
# Required configuration
|
|
1346
|
+
client_id = _get_required("CLIENT_ID", "Client ID")
|
|
1347
|
+
oidc_config_url = _get_required("OIDC_CONFIG_URL", "OpenID Connect configuration URL")
|
|
1348
|
+
|
|
1349
|
+
# Validate OIDC URL format and HTTPS requirement
|
|
1350
|
+
parsed_url = urlparse(oidc_config_url)
|
|
1351
|
+
if not parsed_url.scheme or not parsed_url.hostname:
|
|
1352
|
+
raise ValueError(
|
|
1353
|
+
f"Invalid OIDC configuration URL for {idp_name} OAuth2 IDP: {oidc_config_url}"
|
|
979
1354
|
)
|
|
980
|
-
|
|
1355
|
+
|
|
1356
|
+
is_localhost = parsed_url.hostname in ("localhost", "127.0.0.1", "::1")
|
|
1357
|
+
if parsed_url.scheme != "https" and not is_localhost:
|
|
981
1358
|
raise ValueError(
|
|
982
|
-
f"
|
|
983
|
-
|
|
1359
|
+
f"OIDC configuration URL for {idp_name} OAuth2 IDP "
|
|
1360
|
+
"must use HTTPS (except for localhost)"
|
|
984
1361
|
)
|
|
1362
|
+
|
|
1363
|
+
# Boolean flags
|
|
985
1364
|
allow_sign_up = get_env_oauth2_allow_sign_up(idp_name)
|
|
986
1365
|
auto_login = get_env_oauth2_auto_login(idp_name)
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1366
|
+
use_pkce = _bool_val(f"{idp_prefix}_USE_PKCE", False)
|
|
1367
|
+
|
|
1368
|
+
# Token endpoint auth method validation
|
|
1369
|
+
token_endpoint_auth_method = None
|
|
1370
|
+
if auth_method := _get_optional("TOKEN_ENDPOINT_AUTH_METHOD"):
|
|
1371
|
+
auth_method = auth_method.lower()
|
|
1372
|
+
if auth_method not in _ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS:
|
|
1373
|
+
raise ValueError(
|
|
1374
|
+
f"Invalid TOKEN_ENDPOINT_AUTH_METHOD for {idp_name}. "
|
|
1375
|
+
f"Allowed: {', '.join(sorted(_ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS))}"
|
|
1376
|
+
)
|
|
1377
|
+
token_endpoint_auth_method = auth_method
|
|
1378
|
+
|
|
1379
|
+
# CLIENT_SECRET: required based on TOKEN_ENDPOINT_AUTH_METHOD (OIDC Core §9)
|
|
1380
|
+
client_secret: Optional[str] = None
|
|
1381
|
+
|
|
1382
|
+
# Determine if CLIENT_SECRET is required based on TOKEN_ENDPOINT_AUTH_METHOD:
|
|
1383
|
+
# - "none": CLIENT_SECRET is optional (public clients, RFC 8252 §8.1)
|
|
1384
|
+
# - "client_secret_basic" or "client_secret_post": CLIENT_SECRET is required
|
|
1385
|
+
# - Not set: Default to requiring CLIENT_SECRET (assumes confidential client with
|
|
1386
|
+
# client_secret_basic)
|
|
1387
|
+
#
|
|
1388
|
+
# Note: PKCE (USE_PKCE, RFC 7636) is orthogonal to client authentication. PKCE can be
|
|
1389
|
+
# used with both public clients (no secret) and confidential clients (with secret) to
|
|
1390
|
+
# protect the authorization code from interception.
|
|
1391
|
+
|
|
1392
|
+
if token_endpoint_auth_method == "none":
|
|
1393
|
+
# Public client - no client authentication required
|
|
1394
|
+
client_secret = _get_optional("CLIENT_SECRET")
|
|
1395
|
+
else:
|
|
1396
|
+
# Confidential client (either explicitly set to client_secret_* or using default)
|
|
1397
|
+
# CLIENT_SECRET is required
|
|
1398
|
+
client_secret = _get_required("CLIENT_SECRET", "Client secret")
|
|
1399
|
+
|
|
1400
|
+
# Build scopes: start with required baseline, add custom scopes (deduplicated)
|
|
1401
|
+
scopes = ["openid", "email", "profile"]
|
|
1402
|
+
if custom_scopes := _get_optional("SCOPES"):
|
|
1403
|
+
for scope in custom_scopes.split():
|
|
1404
|
+
if scope and scope not in scopes:
|
|
1405
|
+
scopes.append(scope)
|
|
1406
|
+
|
|
1407
|
+
# Group-based access control
|
|
1408
|
+
groups_attribute_path = _get_optional("GROUPS_ATTRIBUTE_PATH")
|
|
1409
|
+
allowed_groups: list[str] = []
|
|
1410
|
+
if raw_groups := _get_optional("ALLOWED_GROUPS"):
|
|
1411
|
+
# Parse as comma-delimited
|
|
1412
|
+
# Deduplicate while preserving order
|
|
1413
|
+
seen = set()
|
|
1414
|
+
for g in raw_groups.split(","):
|
|
1415
|
+
g = g.strip()
|
|
1416
|
+
if g and g not in seen:
|
|
1417
|
+
allowed_groups.append(g)
|
|
1418
|
+
seen.add(g)
|
|
1419
|
+
|
|
1420
|
+
# Validate: ALLOWED_GROUPS requires GROUPS_ATTRIBUTE_PATH
|
|
1421
|
+
if allowed_groups and not groups_attribute_path:
|
|
1422
|
+
raise ValueError(
|
|
1423
|
+
f"ALLOWED_GROUPS is set for {idp_name} but GROUPS_ATTRIBUTE_PATH is not. "
|
|
1424
|
+
"GROUPS_ATTRIBUTE_PATH must be configured to use group-based access control."
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
# Validate: GROUPS_ATTRIBUTE_PATH requires ALLOWED_GROUPS
|
|
1428
|
+
if groups_attribute_path and not allowed_groups:
|
|
990
1429
|
raise ValueError(
|
|
991
|
-
f"
|
|
992
|
-
"
|
|
1430
|
+
f"GROUPS_ATTRIBUTE_PATH is set for {idp_name} but ALLOWED_GROUPS is not. "
|
|
1431
|
+
"If you want to extract groups, you must specify which groups are allowed. "
|
|
1432
|
+
"If you don't need group-based access control, remove GROUPS_ATTRIBUTE_PATH."
|
|
993
1433
|
)
|
|
1434
|
+
|
|
1435
|
+
# Role mapping
|
|
1436
|
+
role_attribute_path = _get_optional("ROLE_ATTRIBUTE_PATH")
|
|
1437
|
+
role_mapping: dict[str, AssignableUserRoleName] = {}
|
|
1438
|
+
if raw_mapping := _get_optional("ROLE_MAPPING"):
|
|
1439
|
+
# Parse role mapping: "IdpRole1:PhoenixRole,IdpRole2:PhoenixRole"
|
|
1440
|
+
for mapping_pair in raw_mapping.split(","):
|
|
1441
|
+
mapping_pair = mapping_pair.strip()
|
|
1442
|
+
if not mapping_pair:
|
|
1443
|
+
continue
|
|
1444
|
+
|
|
1445
|
+
if ":" not in mapping_pair:
|
|
1446
|
+
raise ValueError(
|
|
1447
|
+
f"Invalid ROLE_MAPPING format for {idp_name}: '{mapping_pair}'. "
|
|
1448
|
+
"Expected format: 'IdpRole:PhoenixRole' "
|
|
1449
|
+
"(e.g., 'Owner:ADMIN,Developer:MEMBER')"
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
idp_role, phoenix_role = mapping_pair.split(":", 1)
|
|
1453
|
+
idp_role = idp_role.strip()
|
|
1454
|
+
phoenix_role_upper = phoenix_role.strip().upper()
|
|
1455
|
+
|
|
1456
|
+
if not idp_role:
|
|
1457
|
+
raise ValueError(
|
|
1458
|
+
f"Invalid ROLE_MAPPING for {idp_name}: "
|
|
1459
|
+
f"IDP role cannot be empty in '{mapping_pair}'"
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
# Explicitly reject SYSTEM role (internal-only)
|
|
1463
|
+
if phoenix_role_upper == "SYSTEM":
|
|
1464
|
+
raise ValueError(
|
|
1465
|
+
f"Invalid ROLE_MAPPING for {idp_name}: "
|
|
1466
|
+
f"SYSTEM role cannot be assigned via OAuth2. "
|
|
1467
|
+
f"SYSTEM is an internal-only role for system API keys. "
|
|
1468
|
+
f"Valid roles are: {', '.join(sorted(_VALID_ROLES))}"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
if phoenix_role_upper not in _VALID_ROLES:
|
|
1472
|
+
valid_roles = ", ".join(sorted(_VALID_ROLES))
|
|
1473
|
+
raise ValueError(
|
|
1474
|
+
f"Invalid ROLE_MAPPING for {idp_name}: "
|
|
1475
|
+
f"'{phoenix_role}' is not a valid Phoenix role. "
|
|
1476
|
+
f"Valid roles are: {valid_roles} (case-insensitive)."
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
role_mapping[idp_role] = phoenix_role_upper # type: ignore[assignment]
|
|
1480
|
+
|
|
1481
|
+
# Get role_attribute_strict setting (defaults to False)
|
|
1482
|
+
role_attribute_strict = _bool_val(f"{idp_prefix}_ROLE_ATTRIBUTE_STRICT", False)
|
|
1483
|
+
|
|
1484
|
+
# Validate role configuration consistency
|
|
1485
|
+
if not role_attribute_path:
|
|
1486
|
+
# If ROLE_ATTRIBUTE_PATH is not configured, other role settings should not be set
|
|
1487
|
+
if role_mapping:
|
|
1488
|
+
raise ValueError(
|
|
1489
|
+
f"Invalid configuration for {idp_name}: ROLE_MAPPING is set but "
|
|
1490
|
+
f"ROLE_ATTRIBUTE_PATH is not configured. ROLE_MAPPING requires "
|
|
1491
|
+
f"ROLE_ATTRIBUTE_PATH to specify where to extract the role from."
|
|
1492
|
+
)
|
|
1493
|
+
if role_attribute_strict:
|
|
1494
|
+
raise ValueError(
|
|
1495
|
+
f"Invalid configuration for {idp_name}: ROLE_ATTRIBUTE_STRICT is set to "
|
|
1496
|
+
f"true but ROLE_ATTRIBUTE_PATH is not configured. ROLE_ATTRIBUTE_STRICT "
|
|
1497
|
+
f"only applies when role extraction is enabled via ROLE_ATTRIBUTE_PATH."
|
|
1498
|
+
)
|
|
1499
|
+
|
|
994
1500
|
return cls(
|
|
995
1501
|
idp_name=idp_name,
|
|
996
|
-
idp_display_name=
|
|
997
|
-
|
|
998
|
-
_get_default_idp_display_name(idp_name),
|
|
999
|
-
),
|
|
1502
|
+
idp_display_name=_get_optional("DISPLAY_NAME")
|
|
1503
|
+
or _get_default_idp_display_name(idp_name),
|
|
1000
1504
|
client_id=client_id,
|
|
1001
1505
|
client_secret=client_secret,
|
|
1002
1506
|
oidc_config_url=oidc_config_url,
|
|
1003
1507
|
allow_sign_up=allow_sign_up,
|
|
1004
1508
|
auto_login=auto_login,
|
|
1509
|
+
use_pkce=use_pkce,
|
|
1510
|
+
token_endpoint_auth_method=token_endpoint_auth_method,
|
|
1511
|
+
scopes=" ".join(scopes),
|
|
1512
|
+
groups_attribute_path=groups_attribute_path,
|
|
1513
|
+
allowed_groups=allowed_groups,
|
|
1514
|
+
role_attribute_path=role_attribute_path,
|
|
1515
|
+
role_mapping=role_mapping,
|
|
1516
|
+
role_attribute_strict=role_attribute_strict,
|
|
1005
1517
|
)
|
|
1006
1518
|
|
|
1007
1519
|
|
|
1520
|
+
class LDAPGroupRoleMapping(TypedDict):
|
|
1521
|
+
"""LDAP group to Phoenix role mapping.
|
|
1522
|
+
|
|
1523
|
+
Attributes:
|
|
1524
|
+
group_dn: LDAP group distinguished name or "*" for wildcard
|
|
1525
|
+
role: Phoenix role name (ADMIN, MEMBER, VIEWER)
|
|
1526
|
+
"""
|
|
1527
|
+
|
|
1528
|
+
group_dn: str
|
|
1529
|
+
role: AssignableUserRoleName
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _is_valid_dn(dn: str) -> bool:
|
|
1533
|
+
"""Check if a string is a valid LDAP DN syntax."""
|
|
1534
|
+
# Empty DN is valid per RFC 4514 §2 - represents the root DSE (zero RDNs)
|
|
1535
|
+
# https://datatracker.ietf.org/doc/html/rfc4514#section-2
|
|
1536
|
+
if not dn.strip():
|
|
1537
|
+
return True
|
|
1538
|
+
try:
|
|
1539
|
+
parse_dn(dn)
|
|
1540
|
+
return True
|
|
1541
|
+
except LDAPInvalidDnError:
|
|
1542
|
+
return False
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
@dataclass(frozen=True)
|
|
1546
|
+
class LDAPConfig:
|
|
1547
|
+
"""LDAP server configuration for authentication.
|
|
1548
|
+
|
|
1549
|
+
Phoenix uses LDAP (RFC 4510-4519) for user authentication against corporate directories
|
|
1550
|
+
like Active Directory, OpenLDAP, and 389 Directory Server.
|
|
1551
|
+
|
|
1552
|
+
User Identity Strategy
|
|
1553
|
+
----------------------
|
|
1554
|
+
Phoenix identifies LDAP users using a stable identifier:
|
|
1555
|
+
|
|
1556
|
+
1. Email (default, recommended for most deployments):
|
|
1557
|
+
When PHOENIX_LDAP_ATTR_UNIQUE_ID is not set, email is used as the identifier.
|
|
1558
|
+
Survives: DN changes, OU moves, renames.
|
|
1559
|
+
If email changes in LDAP: User gets new account (admin can merge manually).
|
|
1560
|
+
|
|
1561
|
+
2. Unique ID attribute (only if you expect email changes):
|
|
1562
|
+
Set PHOENIX_LDAP_ATTR_UNIQUE_ID to use an immutable LDAP attribute:
|
|
1563
|
+
- Active Directory: "objectGUID"
|
|
1564
|
+
- OpenLDAP: "entryUUID" (RFC 4530)
|
|
1565
|
+
- 389 DS: "nsUniqueId"
|
|
1566
|
+
|
|
1567
|
+
IMPORTANT: Only standard UUID-based attributes are supported. Custom attributes
|
|
1568
|
+
containing 16-character string IDs (e.g., "EMP12345ABCD6789") are NOT supported
|
|
1569
|
+
and will be incorrectly converted. The attribute must contain either:
|
|
1570
|
+
- A 16-byte binary UUID (objectGUID)
|
|
1571
|
+
- A string-format UUID (entryUUID, nsUniqueId)
|
|
1572
|
+
|
|
1573
|
+
Use this only if you expect user emails to change (company rebranding, M&A,
|
|
1574
|
+
frequent name changes). Otherwise, email-based identification is simpler.
|
|
1575
|
+
Survives: Everything including email changes.
|
|
1576
|
+
|
|
1577
|
+
Both modes handle DN changes. The only difference is email change handling.
|
|
1578
|
+
DN is NOT used for identity matching (DNs change too frequently).
|
|
1579
|
+
|
|
1580
|
+
Email as Required Attribute:
|
|
1581
|
+
- Email MUST be present in LDAP for authentication to succeed
|
|
1582
|
+
- Used for Phoenix's user email field (UI, notifications, audit logs)
|
|
1583
|
+
- Provides human-readable identifier for operators
|
|
1584
|
+
|
|
1585
|
+
See: internal_docs/specs/ldap-authentication.md for full design rationale.
|
|
1586
|
+
|
|
1587
|
+
Configuration Pattern
|
|
1588
|
+
---------------------
|
|
1589
|
+
This class follows the same pattern as OAuth2ClientConfig:
|
|
1590
|
+
- Load from environment variables via from_env()
|
|
1591
|
+
- Validate required fields and format
|
|
1592
|
+
- Provide sensible defaults for optional fields
|
|
1593
|
+
- Document all fields with inline comments
|
|
1594
|
+
|
|
1595
|
+
Attributes
|
|
1596
|
+
----------
|
|
1597
|
+
Server Connection (RFC 4511):
|
|
1598
|
+
host: LDAP server hostname/IP (required)
|
|
1599
|
+
port: LDAP server port (default: 389 for STARTTLS, 636 for LDAPS)
|
|
1600
|
+
tls_mode: TLS connection mode (default: "starttls")
|
|
1601
|
+
- "starttls": Upgrade from plaintext to TLS on port 389 (recommended)
|
|
1602
|
+
- "ldaps": TLS from connection start on port 636
|
|
1603
|
+
- "none": No encryption (testing only, credentials sent in plaintext)
|
|
1604
|
+
tls_verify: Verify server certificate (default: True, disable only for testing)
|
|
1605
|
+
|
|
1606
|
+
Advanced TLS Configuration (optional, for enterprise deployments):
|
|
1607
|
+
tls_ca_cert_file: Path to custom CA certificate (PEM) for private CAs
|
|
1608
|
+
tls_client_cert_file: Path to client certificate (PEM) for mutual TLS
|
|
1609
|
+
tls_client_key_file: Path to client private key (PEM) for mutual TLS
|
|
1610
|
+
|
|
1611
|
+
Bind Credentials (RFC 4513 §5.1.2 - Simple Authentication):
|
|
1612
|
+
bind_dn: Service account DN for LDAP queries (optional for anonymous bind)
|
|
1613
|
+
bind_password: Service account password (optional for anonymous bind)
|
|
1614
|
+
|
|
1615
|
+
User Search (RFC 4511 §4.5.1):
|
|
1616
|
+
user_search_base_dns: List of base DNs for user searches (searched in order)
|
|
1617
|
+
Example: ["ou=users,dc=example,dc=com"]
|
|
1618
|
+
Multiple: ["ou=employees,dc=corp,dc=com", "ou=contractors,dc=corp,dc=com"]
|
|
1619
|
+
user_search_filter: Filter template with %s placeholder (RFC 4515)
|
|
1620
|
+
Default: "(&(objectClass=user)(sAMAccountName=%s))" (Active Directory)
|
|
1621
|
+
Examples:
|
|
1622
|
+
OpenLDAP: "(&(objectClass=inetOrgPerson)(uid=%s))"
|
|
1623
|
+
389 DS: "(&(objectClass=person)(uid=%s))"
|
|
1624
|
+
|
|
1625
|
+
Attribute Mapping (RFC 2256, RFC 4524):
|
|
1626
|
+
attr_email: Email attribute name (required, e.g., "mail" or "null")
|
|
1627
|
+
- If set to attribute name: MUST be present in LDAP or login fails
|
|
1628
|
+
- If "null": generates null email marker from unique_id
|
|
1629
|
+
(requires attr_unique_id to be set)
|
|
1630
|
+
attr_display_name: Display name attribute (default: "displayName")
|
|
1631
|
+
- Fallback: Uses email prefix if missing
|
|
1632
|
+
attr_member_of: Group membership attribute (default: "memberOf")
|
|
1633
|
+
- Used when group_search_filter is NOT set
|
|
1634
|
+
- Typical values: "memberOf" (AD/OpenLDAP)
|
|
1635
|
+
|
|
1636
|
+
Group Search (for POSIX/OpenLDAP without memberOf overlay):
|
|
1637
|
+
group_search_filter: Filter template with %s placeholder
|
|
1638
|
+
- When SET: Enables POSIX mode, ignores attr_member_of
|
|
1639
|
+
- When NOT SET: Uses attr_member_of from user entry (AD mode)
|
|
1640
|
+
Example: "(&(objectClass=posixGroup)(memberUid=%s))"
|
|
1641
|
+
group_search_filter_user_attr: User attribute to substitute for %s
|
|
1642
|
+
- When SET: Uses that attribute's value from the user entry (e.g., "uid" → "admin")
|
|
1643
|
+
- When NOT SET: Uses the login username directly (what the user typed at login)
|
|
1644
|
+
For POSIX memberUid filters, the default (login username) is typically correct.
|
|
1645
|
+
group_search_base_dns: List of base DNs for group searches
|
|
1646
|
+
- Required when group_search_filter is set
|
|
1647
|
+
Example: ["ou=groups,dc=example,dc=com"]
|
|
1648
|
+
|
|
1649
|
+
Group to Role Mappings:
|
|
1650
|
+
group_role_mappings: Tuple of dicts mapping LDAP groups to Phoenix roles
|
|
1651
|
+
Format: [{"group_dn": "...", "role": "ADMIN|MEMBER|VIEWER"}]
|
|
1652
|
+
Supports wildcard: {"group_dn": "*", "role": "VIEWER"}
|
|
1653
|
+
Note: Phoenix uses "role" (not "org_role") since it has no organization concept
|
|
1654
|
+
|
|
1655
|
+
IMPORTANT - First Match Wins:
|
|
1656
|
+
Mappings are evaluated in configuration order; the FIRST matching group
|
|
1657
|
+
determines the user's role. This is NOT "highest role wins" - if a user
|
|
1658
|
+
belongs to multiple groups, configuration order (not role hierarchy)
|
|
1659
|
+
determines which role they receive.
|
|
1660
|
+
|
|
1661
|
+
This design matches Grafana's LDAP behavior and common authorization
|
|
1662
|
+
patterns (firewall rules, nginx routing, ACLs). It gives administrators
|
|
1663
|
+
explicit control over precedence.
|
|
1664
|
+
|
|
1665
|
+
Best practice: Order mappings from highest privilege to lowest:
|
|
1666
|
+
[
|
|
1667
|
+
{"group_dn": "cn=admins,...", "role": "ADMIN"},
|
|
1668
|
+
{"group_dn": "cn=developers,...", "role": "MEMBER"},
|
|
1669
|
+
{"group_dn": "*", "role": "VIEWER"}
|
|
1670
|
+
]
|
|
1671
|
+
|
|
1672
|
+
With this ordering, a user in both "admins" and "developers" groups
|
|
1673
|
+
receives ADMIN (first match). Reversing the order would give MEMBER.
|
|
1674
|
+
|
|
1675
|
+
Sign-Up Control:
|
|
1676
|
+
allow_sign_up: Auto-create users on first login (default: True)
|
|
1677
|
+
True: New users auto-created on first successful LDAP login
|
|
1678
|
+
False: Admins must pre-create users via GraphQL createUser(auth_method: LDAP)
|
|
1679
|
+
|
|
1680
|
+
Examples
|
|
1681
|
+
--------
|
|
1682
|
+
Active Directory:
|
|
1683
|
+
PHOENIX_LDAP_HOST=ldap.corp.example.com
|
|
1684
|
+
PHOENIX_LDAP_PORT=389
|
|
1685
|
+
PHOENIX_LDAP_TLS_MODE=starttls
|
|
1686
|
+
PHOENIX_LDAP_BIND_DN=cn=service,ou=accounts,dc=corp,dc=example,dc=com
|
|
1687
|
+
PHOENIX_LDAP_BIND_PASSWORD=secret
|
|
1688
|
+
PHOENIX_LDAP_USER_SEARCH_BASE_DNS=["ou=users,dc=corp,dc=example,dc=com"]
|
|
1689
|
+
PHOENIX_LDAP_USER_SEARCH_FILTER=(&(objectClass=user)(sAMAccountName=%s))
|
|
1690
|
+
PHOENIX_LDAP_ATTR_EMAIL=mail
|
|
1691
|
+
PHOENIX_LDAP_ATTR_DISPLAY_NAME=displayName
|
|
1692
|
+
PHOENIX_LDAP_ATTR_MEMBER_OF=memberOf
|
|
1693
|
+
PHOENIX_LDAP_GROUP_ROLE_MAPPINGS=[{"group_dn":"cn=admins,ou=groups,dc=corp,dc=example,dc=com","role":"ADMIN"}]
|
|
1694
|
+
|
|
1695
|
+
OpenLDAP with POSIX groups:
|
|
1696
|
+
PHOENIX_LDAP_HOST=ldap.example.com
|
|
1697
|
+
PHOENIX_LDAP_USER_SEARCH_BASE_DNS=["ou=users,dc=example,dc=com"]
|
|
1698
|
+
PHOENIX_LDAP_USER_SEARCH_FILTER=(&(objectClass=inetOrgPerson)(uid=%s))
|
|
1699
|
+
PHOENIX_LDAP_ATTR_EMAIL=mail
|
|
1700
|
+
PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS=["ou=groups,dc=example,dc=com"]
|
|
1701
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER=(&(objectClass=posixGroup)(memberUid=%s))
|
|
1702
|
+
PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR=uid
|
|
1703
|
+
|
|
1704
|
+
References
|
|
1705
|
+
----------
|
|
1706
|
+
- RFC 4510: LDAP Technical Specification Road Map
|
|
1707
|
+
- RFC 4511: LDAP Protocol
|
|
1708
|
+
- RFC 4513: LDAP Authentication & Security
|
|
1709
|
+
- RFC 4515: LDAP Filter String Format
|
|
1710
|
+
- RFC 4524: LDAP mail attribute definition
|
|
1711
|
+
- RFC 2798: inetOrgPerson object class (includes mail)
|
|
1712
|
+
- Grafana LDAP: https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/ldap/
|
|
1713
|
+
"""
|
|
1714
|
+
|
|
1715
|
+
# Server connection (RFC 4511)
|
|
1716
|
+
hosts: tuple[str, ...]
|
|
1717
|
+
port: int = 389
|
|
1718
|
+
tls_mode: Literal["none", "starttls", "ldaps"] = "starttls"
|
|
1719
|
+
tls_verify: bool = True
|
|
1720
|
+
|
|
1721
|
+
# Advanced TLS configuration (optional, for enterprise deployments)
|
|
1722
|
+
tls_ca_cert_file: str | None = None
|
|
1723
|
+
tls_client_cert_file: str | None = None
|
|
1724
|
+
tls_client_key_file: str | None = None
|
|
1725
|
+
|
|
1726
|
+
# Bind credentials (service account, RFC 4513 §5.1.2)
|
|
1727
|
+
bind_dn: str | None = None
|
|
1728
|
+
bind_password: str | None = None
|
|
1729
|
+
|
|
1730
|
+
# User search (RFC 4511 §4.5.1)
|
|
1731
|
+
user_search_base_dns: tuple[str, ...] = ()
|
|
1732
|
+
user_search_filter: str = "(&(objectClass=user)(sAMAccountName=%s))"
|
|
1733
|
+
|
|
1734
|
+
# Attribute mapping (RFC 2798 §9.1.3, §2.3)
|
|
1735
|
+
attr_email: str | None = "mail" # None if explicitly empty (null email marker mode)
|
|
1736
|
+
attr_display_name: str | None = "displayName"
|
|
1737
|
+
attr_member_of: str | None = "memberOf" # Used when group_search_filter is not set
|
|
1738
|
+
attr_unique_id: str | None = None # Optional: objectGUID (AD), entryUUID (OpenLDAP)
|
|
1739
|
+
|
|
1740
|
+
# Group search (for POSIX/OpenLDAP without memberOf)
|
|
1741
|
+
group_search_base_dns: tuple[str, ...] = ()
|
|
1742
|
+
group_search_filter: str | None = None
|
|
1743
|
+
group_search_filter_user_attr: str | None = None # e.g., "uid" for POSIX memberUid
|
|
1744
|
+
|
|
1745
|
+
# Group to role mappings
|
|
1746
|
+
group_role_mappings: tuple[LDAPGroupRoleMapping, ...] = ()
|
|
1747
|
+
|
|
1748
|
+
# Sign-up control
|
|
1749
|
+
allow_sign_up: bool = True
|
|
1750
|
+
|
|
1751
|
+
def __post_init__(self) -> None:
|
|
1752
|
+
if not self.hosts:
|
|
1753
|
+
raise ValueError(f"{ENV_PHOENIX_LDAP_HOST} must contain at least one host")
|
|
1754
|
+
|
|
1755
|
+
@classmethod
|
|
1756
|
+
def from_env(cls) -> "LDAPConfig" | None:
|
|
1757
|
+
"""Load LDAP config from environment variables.
|
|
1758
|
+
|
|
1759
|
+
Returns:
|
|
1760
|
+
Optional[LDAPConfig]: LDAP configuration if PHOENIX_LDAP_HOST is set, None otherwise
|
|
1761
|
+
|
|
1762
|
+
Raises:
|
|
1763
|
+
ValueError: If configuration is invalid
|
|
1764
|
+
json.JSONDecodeError: If GROUP_ROLE_MAPPINGS is not valid JSON
|
|
1765
|
+
"""
|
|
1766
|
+
host = getenv(ENV_PHOENIX_LDAP_HOST)
|
|
1767
|
+
if not host:
|
|
1768
|
+
return None
|
|
1769
|
+
|
|
1770
|
+
# Import here to avoid circular import at module level
|
|
1771
|
+
from phoenix.server.ldap import canonicalize_dn
|
|
1772
|
+
|
|
1773
|
+
# Normalize and validate host list (remove empty entries from trailing commas, etc.)
|
|
1774
|
+
hosts = tuple(h.strip() for h in host.split(",") if h.strip())
|
|
1775
|
+
if not hosts:
|
|
1776
|
+
raise ValueError(
|
|
1777
|
+
f"{ENV_PHOENIX_LDAP_HOST} must contain at least one non-empty host. "
|
|
1778
|
+
"Example: 'ldap.example.com' or 'dc1.corp.com,dc2.corp.com'"
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
# Parse and validate group role mappings
|
|
1782
|
+
mappings_json = getenv(ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS, "[]")
|
|
1783
|
+
try:
|
|
1784
|
+
group_role_mappings_list = json.loads(mappings_json)
|
|
1785
|
+
except json.JSONDecodeError as e:
|
|
1786
|
+
raise ValueError(
|
|
1787
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS} is not valid JSON: {e}. "
|
|
1788
|
+
f"Expected format: [{{'group_dn': '...', 'role': 'ADMIN'}}]"
|
|
1789
|
+
)
|
|
1790
|
+
|
|
1791
|
+
# Validate role mappings structure
|
|
1792
|
+
if not isinstance(group_role_mappings_list, list):
|
|
1793
|
+
raise ValueError(
|
|
1794
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS} must be a JSON array. "
|
|
1795
|
+
f"Expected format: [{{'group_dn': '...', 'role': 'ADMIN'}}]"
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
for idx, mapping in enumerate(group_role_mappings_list):
|
|
1799
|
+
if not isinstance(mapping, dict):
|
|
1800
|
+
raise ValueError(
|
|
1801
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS}[{idx}] must be an object. "
|
|
1802
|
+
f"Got: {type(mapping).__name__}"
|
|
1803
|
+
)
|
|
1804
|
+
if "group_dn" not in mapping:
|
|
1805
|
+
raise ValueError(
|
|
1806
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS}[{idx}] "
|
|
1807
|
+
"missing required field 'group_dn'"
|
|
1808
|
+
)
|
|
1809
|
+
if "role" not in mapping:
|
|
1810
|
+
raise ValueError(
|
|
1811
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS}[{idx}] missing required field 'role'"
|
|
1812
|
+
)
|
|
1813
|
+
if not isinstance(mapping["group_dn"], str) or not mapping["group_dn"].strip():
|
|
1814
|
+
raise ValueError(
|
|
1815
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS}[{idx}].group_dn "
|
|
1816
|
+
"must be a non-empty string"
|
|
1817
|
+
)
|
|
1818
|
+
# Validate DN syntax and canonicalize (except for wildcard "*")
|
|
1819
|
+
raw_group_dn = mapping["group_dn"].strip()
|
|
1820
|
+
group_dn = canonicalize_dn(raw_group_dn) if raw_group_dn != "*" else raw_group_dn
|
|
1821
|
+
if group_dn is None:
|
|
1822
|
+
raise ValueError(
|
|
1823
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS}[{idx}].group_dn "
|
|
1824
|
+
f"has invalid LDAP DN syntax: '{raw_group_dn}'. "
|
|
1825
|
+
f"Expected format: 'cn=GroupName,ou=Groups,dc=example,dc=com'"
|
|
1826
|
+
)
|
|
1827
|
+
# Normalize role to uppercase and validate
|
|
1828
|
+
role_upper = mapping["role"].upper() if isinstance(mapping["role"], str) else ""
|
|
1829
|
+
if role_upper not in _VALID_ROLES:
|
|
1830
|
+
raise ValueError(
|
|
1831
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS}[{idx}]: "
|
|
1832
|
+
f"role must be one of {_VALID_ROLES} (case-insensitive). "
|
|
1833
|
+
f"Got: '{mapping['role']}'"
|
|
1834
|
+
)
|
|
1835
|
+
mapping["group_dn"] = group_dn
|
|
1836
|
+
mapping["role"] = role_upper
|
|
1837
|
+
|
|
1838
|
+
# Require at least one role mapping to prevent silent authentication failures
|
|
1839
|
+
# Without mappings, all LDAP users would be denied access with only a debug log,
|
|
1840
|
+
# which is confusing for operators. Fail fast at startup with clear guidance.
|
|
1841
|
+
if not group_role_mappings_list:
|
|
1842
|
+
raise ValueError(
|
|
1843
|
+
f"{ENV_PHOENIX_LDAP_GROUP_ROLE_MAPPINGS} must contain at least one mapping. "
|
|
1844
|
+
f'Example: \'[{{"group_dn": "*", "role": "MEMBER"}}]\' '
|
|
1845
|
+
f"(wildcard '*' grants MEMBER role to all authenticated LDAP users)"
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
# Validate TLS mode
|
|
1849
|
+
tls_mode_str = getenv(ENV_PHOENIX_LDAP_TLS_MODE, "starttls").lower()
|
|
1850
|
+
if tls_mode_str not in ("none", "starttls", "ldaps"):
|
|
1851
|
+
raise ValueError(
|
|
1852
|
+
f"{ENV_PHOENIX_LDAP_TLS_MODE} must be 'none', 'starttls', or 'ldaps'. "
|
|
1853
|
+
f"Got: '{tls_mode_str}'"
|
|
1854
|
+
)
|
|
1855
|
+
tls_mode = cast(Literal["none", "starttls", "ldaps"], tls_mode_str)
|
|
1856
|
+
|
|
1857
|
+
# Parse and validate group_search_base_dns (JSON array of base DNs, optional)
|
|
1858
|
+
attr_member_of = getenv(ENV_PHOENIX_LDAP_ATTR_MEMBER_OF, "memberOf").strip() or None
|
|
1859
|
+
group_search_base_dns_json = getenv(ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS, "")
|
|
1860
|
+
group_search_filter = getenv(ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER)
|
|
1861
|
+
group_search_filter_user_attr = getenv(ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR)
|
|
1862
|
+
|
|
1863
|
+
if group_search_filter and "%s" not in group_search_filter:
|
|
1864
|
+
raise ValueError(
|
|
1865
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER} must contain '%s' placeholder. "
|
|
1866
|
+
f"Got: '{group_search_filter}'"
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
group_search_base_dns_list: list[str] = []
|
|
1870
|
+
if group_search_base_dns_json:
|
|
1871
|
+
try:
|
|
1872
|
+
group_search_base_dns_list = json.loads(group_search_base_dns_json)
|
|
1873
|
+
except json.JSONDecodeError as e:
|
|
1874
|
+
raise ValueError(
|
|
1875
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS} is not valid JSON: {e}. "
|
|
1876
|
+
"Expected format: '[\"ou=groups,dc=example,dc=com\"]'"
|
|
1877
|
+
)
|
|
1878
|
+
if not isinstance(group_search_base_dns_list, list):
|
|
1879
|
+
raise ValueError(
|
|
1880
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS} must be a JSON array. "
|
|
1881
|
+
"Expected format: '[\"ou=groups,dc=example,dc=com\"]'"
|
|
1882
|
+
)
|
|
1883
|
+
for idx, base_dn in enumerate(group_search_base_dns_list):
|
|
1884
|
+
if not isinstance(base_dn, str):
|
|
1885
|
+
raise ValueError(
|
|
1886
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS}[{idx}] must be a string"
|
|
1887
|
+
)
|
|
1888
|
+
stripped = base_dn.strip()
|
|
1889
|
+
if not _is_valid_dn(stripped):
|
|
1890
|
+
raise ValueError(
|
|
1891
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS}[{idx}] "
|
|
1892
|
+
f"has invalid LDAP DN syntax: '{base_dn}'. "
|
|
1893
|
+
f"Expected format: 'ou=groups,dc=example,dc=com'"
|
|
1894
|
+
)
|
|
1895
|
+
group_search_base_dns_list[idx] = stripped
|
|
1896
|
+
|
|
1897
|
+
# Validate group search configuration: if filter is set, base DNs are required
|
|
1898
|
+
if group_search_filter and not group_search_base_dns_list:
|
|
1899
|
+
raise ValueError(
|
|
1900
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER} is set but "
|
|
1901
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_BASE_DNS} is missing. "
|
|
1902
|
+
f"Both are required for POSIX group search."
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
# Validate group_search_filter_user_attr: only valid when group_search_filter is set
|
|
1906
|
+
if group_search_filter_user_attr and not group_search_filter:
|
|
1907
|
+
raise ValueError(
|
|
1908
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR} is set but "
|
|
1909
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER} is not. "
|
|
1910
|
+
f"The user attribute setting only applies to group search filter mode."
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
# Validate attribute name format (no spaces)
|
|
1914
|
+
if group_search_filter_user_attr and " " in group_search_filter_user_attr:
|
|
1915
|
+
suggestion = group_search_filter_user_attr.replace(" ", "")
|
|
1916
|
+
raise ValueError(
|
|
1917
|
+
f"{ENV_PHOENIX_LDAP_GROUP_SEARCH_FILTER_USER_ATTR}="
|
|
1918
|
+
f"'{group_search_filter_user_attr}' contains spaces. "
|
|
1919
|
+
f"LDAP attribute names do not contain spaces. "
|
|
1920
|
+
f"Did you mean '{suggestion}'?"
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1923
|
+
# Security warnings (log, don't fail)
|
|
1924
|
+
tls_verify = _bool_val(ENV_PHOENIX_LDAP_TLS_VERIFY, True)
|
|
1925
|
+
if tls_mode == "none":
|
|
1926
|
+
logger.warning(
|
|
1927
|
+
f"{ENV_PHOENIX_LDAP_TLS_MODE}=none - credentials will be sent in plaintext! "
|
|
1928
|
+
"This is insecure for production."
|
|
1929
|
+
)
|
|
1930
|
+
if tls_mode != "none" and not tls_verify:
|
|
1931
|
+
logger.warning(
|
|
1932
|
+
f"{ENV_PHOENIX_LDAP_TLS_VERIFY} is false - certificates will not be validated! "
|
|
1933
|
+
"This is insecure for production (vulnerable to MITM attacks)."
|
|
1934
|
+
)
|
|
1935
|
+
|
|
1936
|
+
# Parse and validate user_search_base_dns (JSON array of base DNs)
|
|
1937
|
+
user_search_base_dns_json = getenv(ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS, "")
|
|
1938
|
+
if not user_search_base_dns_json:
|
|
1939
|
+
raise ValueError(
|
|
1940
|
+
f"{ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS} must be set. "
|
|
1941
|
+
"Example: '[\"OU=Users,DC=corp,DC=com\"]'"
|
|
1942
|
+
)
|
|
1943
|
+
user_search_base_dns_list: list[str] = []
|
|
1944
|
+
try:
|
|
1945
|
+
user_search_base_dns_list = json.loads(user_search_base_dns_json)
|
|
1946
|
+
except json.JSONDecodeError as e:
|
|
1947
|
+
raise ValueError(
|
|
1948
|
+
f"{ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS} is not valid JSON: {e}. "
|
|
1949
|
+
"Expected format: '[\"OU=Users,DC=corp,DC=com\"]'"
|
|
1950
|
+
)
|
|
1951
|
+
if not isinstance(user_search_base_dns_list, list):
|
|
1952
|
+
raise ValueError(
|
|
1953
|
+
f"{ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS} must be a JSON array. "
|
|
1954
|
+
"Expected format: '[\"OU=Users,DC=corp,DC=com\"]'"
|
|
1955
|
+
)
|
|
1956
|
+
if not user_search_base_dns_list:
|
|
1957
|
+
raise ValueError(
|
|
1958
|
+
f"{ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS} must contain at least one base DN. "
|
|
1959
|
+
"Example: '[\"OU=Users,DC=corp,DC=com\"]'"
|
|
1960
|
+
)
|
|
1961
|
+
for idx, base_dn in enumerate(user_search_base_dns_list):
|
|
1962
|
+
if not isinstance(base_dn, str):
|
|
1963
|
+
raise ValueError(f"{ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS}[{idx}] must be a string")
|
|
1964
|
+
stripped = base_dn.strip()
|
|
1965
|
+
if not _is_valid_dn(stripped):
|
|
1966
|
+
raise ValueError(
|
|
1967
|
+
f"{ENV_PHOENIX_LDAP_USER_SEARCH_BASE_DNS}[{idx}] "
|
|
1968
|
+
f"has invalid LDAP DN syntax: '{base_dn}'. "
|
|
1969
|
+
f"Expected format: 'ou=users,dc=example,dc=com'"
|
|
1970
|
+
)
|
|
1971
|
+
user_search_base_dns_list[idx] = stripped
|
|
1972
|
+
|
|
1973
|
+
# Parse allow_sign_up
|
|
1974
|
+
allow_sign_up = _bool_val(ENV_PHOENIX_LDAP_ALLOW_SIGN_UP, True)
|
|
1975
|
+
|
|
1976
|
+
# Determine default port based on TLS mode (if not explicitly set)
|
|
1977
|
+
# STARTTLS: port 389 (plaintext, then upgrade)
|
|
1978
|
+
# LDAPS: port 636 (TLS from start)
|
|
1979
|
+
default_port = 636 if tls_mode == "ldaps" else 389
|
|
1980
|
+
port = _int_val(ENV_PHOENIX_LDAP_PORT, default_port)
|
|
1981
|
+
|
|
1982
|
+
# Parse advanced TLS configuration (optional)
|
|
1983
|
+
tls_ca_cert_file = getenv(ENV_PHOENIX_LDAP_TLS_CA_CERT_FILE)
|
|
1984
|
+
tls_client_cert_file = getenv(ENV_PHOENIX_LDAP_TLS_CLIENT_CERT_FILE)
|
|
1985
|
+
tls_client_key_file = getenv(ENV_PHOENIX_LDAP_TLS_CLIENT_KEY_FILE)
|
|
1986
|
+
|
|
1987
|
+
# Validate mutual TLS configuration (both cert and key required)
|
|
1988
|
+
if tls_client_cert_file and not tls_client_key_file:
|
|
1989
|
+
raise ValueError(
|
|
1990
|
+
f"{ENV_PHOENIX_LDAP_TLS_CLIENT_CERT_FILE} requires "
|
|
1991
|
+
f"{ENV_PHOENIX_LDAP_TLS_CLIENT_KEY_FILE} to also be set"
|
|
1992
|
+
)
|
|
1993
|
+
if tls_client_key_file and not tls_client_cert_file:
|
|
1994
|
+
raise ValueError(
|
|
1995
|
+
f"{ENV_PHOENIX_LDAP_TLS_CLIENT_KEY_FILE} requires "
|
|
1996
|
+
f"{ENV_PHOENIX_LDAP_TLS_CLIENT_CERT_FILE} to also be set"
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
# Validate file paths exist
|
|
2000
|
+
for env_var, file_path in [
|
|
2001
|
+
(ENV_PHOENIX_LDAP_TLS_CA_CERT_FILE, tls_ca_cert_file),
|
|
2002
|
+
(ENV_PHOENIX_LDAP_TLS_CLIENT_CERT_FILE, tls_client_cert_file),
|
|
2003
|
+
(ENV_PHOENIX_LDAP_TLS_CLIENT_KEY_FILE, tls_client_key_file),
|
|
2004
|
+
]:
|
|
2005
|
+
if file_path and not os.path.isfile(file_path):
|
|
2006
|
+
raise ValueError(f"{env_var}='{file_path}' does not exist or is not a file")
|
|
2007
|
+
|
|
2008
|
+
# Parse attribute names
|
|
2009
|
+
# attr_email behavior:
|
|
2010
|
+
# - Not set at all → "mail" (backwards compatibility, with deprecation warning)
|
|
2011
|
+
# - "null" sentinel → None (null email marker mode, platform-safe)
|
|
2012
|
+
# - Explicitly empty (PHOENIX_LDAP_ATTR_EMAIL=) → None (null email marker mode)
|
|
2013
|
+
# - Set to value → use that value
|
|
2014
|
+
attr_email_raw = getenv(ENV_PHOENIX_LDAP_ATTR_EMAIL)
|
|
2015
|
+
if attr_email_raw is None:
|
|
2016
|
+
logger.warning(
|
|
2017
|
+
f"{ENV_PHOENIX_LDAP_ATTR_EMAIL} is not set and will be required in the next "
|
|
2018
|
+
f"major version. Set to 'mail' if your LDAP has email, or 'null' if not."
|
|
2019
|
+
)
|
|
2020
|
+
attr_email: str | None = "mail" # Default for backwards compatibility
|
|
2021
|
+
elif attr_email_raw == "" or attr_email_raw.lower() == "null":
|
|
2022
|
+
attr_email = None # Empty or "null" sentinel → null email marker mode
|
|
2023
|
+
else:
|
|
2024
|
+
attr_email = attr_email_raw
|
|
2025
|
+
attr_display_name = (
|
|
2026
|
+
getenv(ENV_PHOENIX_LDAP_ATTR_DISPLAY_NAME, "displayName").strip() or None
|
|
2027
|
+
)
|
|
2028
|
+
attr_unique_id = getenv(ENV_PHOENIX_LDAP_ATTR_UNIQUE_ID, "").strip() or None
|
|
2029
|
+
|
|
2030
|
+
# Validate null email marker mode constraints
|
|
2031
|
+
# When attr_email is "null", we generate markers from unique_id instead
|
|
2032
|
+
if not attr_email:
|
|
2033
|
+
# Constraint 1: unique_id is required for user lookup and marker generation
|
|
2034
|
+
if not attr_unique_id:
|
|
2035
|
+
raise ValueError(
|
|
2036
|
+
f"{ENV_PHOENIX_LDAP_ATTR_UNIQUE_ID} is required when "
|
|
2037
|
+
f"{ENV_PHOENIX_LDAP_ATTR_EMAIL} is 'null'. "
|
|
2038
|
+
f"Without email, unique_id is needed to identify returning users."
|
|
2039
|
+
)
|
|
2040
|
+
# Constraint 2: allow_sign_up must be True (can't pre-provision without unique_id)
|
|
2041
|
+
if not allow_sign_up:
|
|
2042
|
+
raise ValueError(
|
|
2043
|
+
f"{ENV_PHOENIX_LDAP_ALLOW_SIGN_UP} must be True when "
|
|
2044
|
+
f"{ENV_PHOENIX_LDAP_ATTR_EMAIL} is 'null'. "
|
|
2045
|
+
f"Null email markers require auto-provisioning on first login."
|
|
2046
|
+
)
|
|
2047
|
+
# Constraint 3: PHOENIX_ADMINS is not supported (can't compute marker without unique_id)
|
|
2048
|
+
if get_env_admins():
|
|
2049
|
+
raise ValueError(
|
|
2050
|
+
f"PHOENIX_ADMINS is not supported when {ENV_PHOENIX_LDAP_ATTR_EMAIL} "
|
|
2051
|
+
f"is 'null'. Use PHOENIX_LDAP_GROUP_ROLE_MAPPINGS to assign roles."
|
|
2052
|
+
)
|
|
2053
|
+
|
|
2054
|
+
# Validate attribute names don't contain spaces
|
|
2055
|
+
# LDAP attribute names (e.g., objectGUID, entryUUID, mail) never contain spaces.
|
|
2056
|
+
# A space in the config is likely a typo that would cause silent failures.
|
|
2057
|
+
for env_var, attr_value in [
|
|
2058
|
+
(ENV_PHOENIX_LDAP_ATTR_EMAIL, attr_email),
|
|
2059
|
+
(ENV_PHOENIX_LDAP_ATTR_DISPLAY_NAME, attr_display_name),
|
|
2060
|
+
(ENV_PHOENIX_LDAP_ATTR_MEMBER_OF, attr_member_of),
|
|
2061
|
+
(ENV_PHOENIX_LDAP_ATTR_UNIQUE_ID, attr_unique_id),
|
|
2062
|
+
]:
|
|
2063
|
+
if attr_value and " " in attr_value:
|
|
2064
|
+
suggestion = attr_value.replace(" ", "")
|
|
2065
|
+
raise ValueError(
|
|
2066
|
+
f"{env_var}='{attr_value}' contains spaces. "
|
|
2067
|
+
f"LDAP attribute names do not contain spaces. "
|
|
2068
|
+
f"Did you mean '{suggestion}'?"
|
|
2069
|
+
)
|
|
2070
|
+
|
|
2071
|
+
# Parse and validate search filters
|
|
2072
|
+
user_search_filter = getenv(
|
|
2073
|
+
ENV_PHOENIX_LDAP_USER_SEARCH_FILTER, "(&(objectClass=user)(sAMAccountName=%s))"
|
|
2074
|
+
)
|
|
2075
|
+
if "%s" not in user_search_filter:
|
|
2076
|
+
raise ValueError(
|
|
2077
|
+
f"{ENV_PHOENIX_LDAP_USER_SEARCH_FILTER} must contain '%s' placeholder "
|
|
2078
|
+
f"for username. Got: '{user_search_filter}'"
|
|
2079
|
+
)
|
|
2080
|
+
|
|
2081
|
+
bind_dn = getenv(ENV_PHOENIX_LDAP_BIND_DN, "").strip() or None
|
|
2082
|
+
if bind_dn and not _is_valid_dn(bind_dn):
|
|
2083
|
+
raise ValueError(
|
|
2084
|
+
f"{ENV_PHOENIX_LDAP_BIND_DN} has invalid LDAP DN syntax: '{bind_dn}'. "
|
|
2085
|
+
f"Expected format: 'cn=service,ou=accounts,dc=example,dc=com'"
|
|
2086
|
+
)
|
|
2087
|
+
bind_password = getenv(ENV_PHOENIX_LDAP_BIND_PASSWORD) or None
|
|
2088
|
+
if bind_dn and not bind_password:
|
|
2089
|
+
raise ValueError(
|
|
2090
|
+
f"{ENV_PHOENIX_LDAP_BIND_DN} is set but {ENV_PHOENIX_LDAP_BIND_PASSWORD} is "
|
|
2091
|
+
"missing. Both are required for service account authentication."
|
|
2092
|
+
)
|
|
2093
|
+
if bind_password and not bind_dn:
|
|
2094
|
+
raise ValueError(
|
|
2095
|
+
f"{ENV_PHOENIX_LDAP_BIND_PASSWORD} is set but {ENV_PHOENIX_LDAP_BIND_DN} is "
|
|
2096
|
+
"missing. Both are required for service account authentication."
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
return cls(
|
|
2100
|
+
hosts=hosts,
|
|
2101
|
+
port=port,
|
|
2102
|
+
tls_mode=tls_mode,
|
|
2103
|
+
tls_verify=tls_verify,
|
|
2104
|
+
tls_ca_cert_file=tls_ca_cert_file,
|
|
2105
|
+
tls_client_cert_file=tls_client_cert_file,
|
|
2106
|
+
tls_client_key_file=tls_client_key_file,
|
|
2107
|
+
bind_dn=bind_dn,
|
|
2108
|
+
bind_password=bind_password,
|
|
2109
|
+
user_search_base_dns=tuple(user_search_base_dns_list),
|
|
2110
|
+
user_search_filter=user_search_filter,
|
|
2111
|
+
attr_email=attr_email,
|
|
2112
|
+
attr_display_name=attr_display_name,
|
|
2113
|
+
attr_member_of=attr_member_of,
|
|
2114
|
+
attr_unique_id=attr_unique_id,
|
|
2115
|
+
group_search_base_dns=tuple(group_search_base_dns_list),
|
|
2116
|
+
group_search_filter=group_search_filter,
|
|
2117
|
+
group_search_filter_user_attr=group_search_filter_user_attr,
|
|
2118
|
+
group_role_mappings=tuple(group_role_mappings_list),
|
|
2119
|
+
allow_sign_up=allow_sign_up,
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
_OAUTH2_CONFIG_SUFFIXES = (
|
|
2124
|
+
"DISPLAY_NAME", # User-friendly name shown in login UI
|
|
2125
|
+
"CLIENT_ID", # OAuth2 client ID from your identity provider (RFC 6749 §2.2)
|
|
2126
|
+
# OAuth2 client secret (RFC 6749 §2.3.1, required by default, optional with auth method "none")
|
|
2127
|
+
"CLIENT_SECRET",
|
|
2128
|
+
"OIDC_CONFIG_URL", # OpenID Connect discovery URL (.well-known/openid-configuration)
|
|
2129
|
+
"ALLOW_SIGN_UP", # Whether to allow new user registration (default: true)
|
|
2130
|
+
"AUTO_LOGIN", # Automatically redirect to this provider (default: false)
|
|
2131
|
+
"USE_PKCE", # Enable PKCE for authorization code protection (RFC 7636, default: false)
|
|
2132
|
+
"TOKEN_ENDPOINT_AUTH_METHOD", # How to authenticate at token endpoint (OIDC Core §9)
|
|
2133
|
+
# Additional OAuth2 scopes beyond "openid email profile" (RFC 6749 §3.3: space-delimited)
|
|
2134
|
+
"SCOPES",
|
|
2135
|
+
"GROUPS_ATTRIBUTE_PATH", # JMESPath expression to extract groups from ID token
|
|
2136
|
+
"ALLOWED_GROUPS", # Comma-separated list of groups allowed to sign in
|
|
2137
|
+
"ROLE_ATTRIBUTE_PATH", # JMESPath expression to extract role from ID token
|
|
2138
|
+
"ROLE_MAPPING", # Comma-separated list of IDP role to Phoenix role mappings
|
|
2139
|
+
"ROLE_ATTRIBUTE_STRICT", # Whether to deny access if role cannot be extracted/mapped
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
_OAUTH2_ENV_VAR_PATTERN = re.compile(
|
|
2144
|
+
rf"^PHOENIX_OAUTH2_(\w+)_({'|'.join(_OAUTH2_CONFIG_SUFFIXES)})$"
|
|
2145
|
+
)
|
|
2146
|
+
|
|
2147
|
+
|
|
1008
2148
|
def get_env_oauth2_settings() -> list[OAuth2ClientConfig]:
|
|
1009
2149
|
"""
|
|
1010
2150
|
Retrieves and validates OAuth2/OpenID Connect (OIDC) identity provider configurations from environment variables.
|
|
1011
2151
|
|
|
1012
2152
|
This function scans the environment for OAuth2 configuration variables and returns a list of
|
|
1013
|
-
configured identity providers.
|
|
2153
|
+
configured identity providers. Multiple identity providers can be configured simultaneously,
|
|
2154
|
+
and users will see all enabled providers as login options in the Phoenix UI.
|
|
1014
2155
|
|
|
1015
2156
|
Environment Variable Pattern:
|
|
1016
2157
|
PHOENIX_OAUTH2_{IDP_NAME}_{CONFIG_TYPE}
|
|
1017
2158
|
|
|
2159
|
+
Where {IDP_NAME} is any alphanumeric identifier you choose (e.g., GOOGLE, OKTA, KEYCLOAK).
|
|
2160
|
+
The name is case-insensitive and used to group related configuration variables. You can use
|
|
2161
|
+
any name that makes sense for your organization (e.g., COMPANY_SSO, INTERNAL_AUTH).
|
|
2162
|
+
|
|
1018
2163
|
Required Environment Variables for each IDP:
|
|
1019
2164
|
- PHOENIX_OAUTH2_{IDP_NAME}_CLIENT_ID: The OAuth2 client ID issued by the identity provider
|
|
1020
|
-
|
|
1021
|
-
- PHOENIX_OAUTH2_{IDP_NAME}
|
|
2165
|
+
|
|
2166
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_CLIENT_SECRET: The OAuth2 client secret issued by the identity provider.
|
|
2167
|
+
Required by default for confidential clients. Only optional when TOKEN_ENDPOINT_AUTH_METHOD is
|
|
2168
|
+
explicitly set to "none" (for public clients without client authentication).
|
|
2169
|
+
|
|
2170
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_OIDC_CONFIG_URL: The OpenID Connect configuration URL (must be HTTPS
|
|
2171
|
+
except for localhost). This URL typically ends with /.well-known/openid-configuration and is
|
|
2172
|
+
used to auto-discover OAuth2 endpoints.
|
|
1022
2173
|
|
|
1023
2174
|
Optional Environment Variables:
|
|
1024
|
-
- PHOENIX_OAUTH2_{IDP_NAME}_DISPLAY_NAME: A user-friendly name for the identity provider
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
2175
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_DISPLAY_NAME: A user-friendly name for the identity provider shown in the UI
|
|
2176
|
+
|
|
2177
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_ALLOW_SIGN_UP: Whether to allow new user registration via this OAuth2 provider
|
|
2178
|
+
(defaults to True). When set to False, only existing users can sign in.
|
|
2179
|
+
|
|
2180
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_AUTO_LOGIN: Automatically redirect to this provider's login page, skipping
|
|
2181
|
+
the Phoenix login screen (defaults to False). Useful for single sign-on deployments.
|
|
2182
|
+
Note: Only one provider should have AUTO_LOGIN enabled if you configure multiple IDPs.
|
|
2183
|
+
|
|
2184
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_USE_PKCE: Enable PKCE (Proof Key for Code Exchange) with S256 code challenge
|
|
2185
|
+
method for enhanced security. PKCE protects the authorization code from interception and can be used
|
|
2186
|
+
with both public clients and confidential clients. This setting is orthogonal to client authentication -
|
|
2187
|
+
whether CLIENT_SECRET is required is determined solely by TOKEN_ENDPOINT_AUTH_METHOD, not by USE_PKCE.
|
|
2188
|
+
|
|
2189
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_TOKEN_ENDPOINT_AUTH_METHOD: OAuth2 token endpoint authentication method.
|
|
2190
|
+
This setting determines how the client authenticates with the token endpoint and whether
|
|
2191
|
+
CLIENT_SECRET is required. If not set, defaults to requiring CLIENT_SECRET (confidential client).
|
|
2192
|
+
|
|
2193
|
+
Options:
|
|
2194
|
+
• client_secret_basic: Send credentials in HTTP Basic Auth header (most common).
|
|
2195
|
+
CLIENT_SECRET is required. This is the assumed default behavior if not set.
|
|
2196
|
+
• client_secret_post: Send credentials in POST body (required by some providers).
|
|
2197
|
+
CLIENT_SECRET is required.
|
|
2198
|
+
• none: No client authentication (for public clients).
|
|
2199
|
+
CLIENT_SECRET is not required. Use this for public clients that cannot
|
|
2200
|
+
securely store a client secret, typically in combination with PKCE.
|
|
2201
|
+
|
|
2202
|
+
Most providers work with the default behavior. Set this explicitly only if your provider requires
|
|
2203
|
+
a specific method or if you're configuring a public client.
|
|
2204
|
+
|
|
2205
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_SCOPES: Additional OAuth2 scopes to request (space-separated).
|
|
2206
|
+
These are added to the required baseline scopes "openid email profile". For example, set to
|
|
2207
|
+
"offline_access groups" to request refresh tokens and group information. The baseline scopes
|
|
2208
|
+
are always included and cannot be removed.
|
|
2209
|
+
|
|
2210
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_GROUPS_ATTRIBUTE_PATH: JMESPath expression to extract group/role claims
|
|
2211
|
+
from the OIDC ID token or userinfo endpoint response. See https://jmespath.org for full syntax.
|
|
2212
|
+
|
|
2213
|
+
The path navigates nested JSON structures to find group/role information. This claim is checked
|
|
2214
|
+
from both the ID token and userinfo endpoint (if available). The result is normalized to a list
|
|
2215
|
+
of strings for group matching.
|
|
2216
|
+
|
|
2217
|
+
⚠️ IMPORTANT: Claim keys with special characters (colons, dots, slashes, hyphens, etc.) MUST be
|
|
2218
|
+
enclosed in double quotes. Examples:
|
|
2219
|
+
• Auth0 namespace: `"https://myapp.com/groups"` (NOT `https://myapp.com/groups`)
|
|
2220
|
+
• AWS Cognito: `"cognito:groups"` (NOT `cognito:groups`)
|
|
2221
|
+
• Keycloak app: `resource_access."my-app".roles` (quotes only around special chars)
|
|
2222
|
+
|
|
2223
|
+
Common JMESPath patterns:
|
|
2224
|
+
• Simple keys: `groups` - extracts top-level array
|
|
2225
|
+
• Nested keys: `resource_access.phoenix.roles` - dot notation for nested objects
|
|
2226
|
+
• Array projection: `teams[*].name` - extracts 'name' field from each object in array
|
|
2227
|
+
• Array indexing: `groups[0]` - gets first element
|
|
2228
|
+
|
|
2229
|
+
Common provider examples:
|
|
2230
|
+
• Google Workspace: `groups`
|
|
2231
|
+
• Azure AD/Entra ID: `roles` or `groups`
|
|
2232
|
+
• Keycloak: `resource_access.phoenix.roles` (nested structure)
|
|
2233
|
+
• AWS Cognito: `"cognito:groups"` (use quotes for colon in key name)
|
|
2234
|
+
• Okta: `groups`
|
|
2235
|
+
• Auth0 (custom namespace): `"https://myapp.com/groups"` (use quotes for special chars)
|
|
2236
|
+
• Custom objects: `teams[*].name` (extract field from array of objects)
|
|
2237
|
+
|
|
2238
|
+
If not set, group-based access control is disabled for this provider.
|
|
2239
|
+
|
|
2240
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_ALLOWED_GROUPS: Comma-separated list of group names that
|
|
2241
|
+
are permitted to sign in. Users must belong to at least one of these groups (extracted via
|
|
2242
|
+
GROUPS_ATTRIBUTE_PATH) to authenticate successfully.
|
|
2243
|
+
|
|
2244
|
+
Example:
|
|
2245
|
+
PHOENIX_OAUTH2_OKTA_ALLOWED_GROUPS="admin,developers,viewers"
|
|
2246
|
+
|
|
2247
|
+
Works together with GROUPS_ATTRIBUTE_PATH to implement group-based access control. If not set,
|
|
2248
|
+
all authenticated users can sign in (subject to ALLOW_SIGN_UP restrictions).
|
|
2249
|
+
|
|
2250
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_ROLE_ATTRIBUTE_PATH: JMESPath expression to extract user role claim
|
|
2251
|
+
from the OIDC ID token or userinfo endpoint response. Similar to GROUPS_ATTRIBUTE_PATH but for
|
|
2252
|
+
extracting a single role value. See https://jmespath.org for full syntax.
|
|
2253
|
+
|
|
2254
|
+
⚠️ IMPORTANT: Claim keys with special characters MUST be enclosed in double quotes.
|
|
2255
|
+
Examples: `"https://myapp.com/role"`, `"custom:role"`, `user.profile."app-role"`
|
|
2256
|
+
|
|
2257
|
+
Common patterns:
|
|
2258
|
+
• Simple key: `role` - extracts top-level string
|
|
2259
|
+
• Nested key: `user.organization.role` - dot notation for nested objects
|
|
2260
|
+
• Array element: `roles[0]` - gets first role from array
|
|
2261
|
+
• Constant value: `'MEMBER'` - assigns a fixed role to all users from this IDP (no mapping needed)
|
|
2262
|
+
• Conditional logic: `contains(groups[*], 'admin') && 'ADMIN' || 'VIEWER'` - compute role
|
|
2263
|
+
from group membership using logical operators (returns Phoenix role directly, no mapping needed)
|
|
2264
|
+
|
|
2265
|
+
This claim is used with ROLE_MAPPING to automatically assign Phoenix roles (ADMIN, MEMBER, VIEWER)
|
|
2266
|
+
based on the user's role in your identity provider. The extracted role value is matched against
|
|
2267
|
+
keys in ROLE_MAPPING to determine the Phoenix role.
|
|
2268
|
+
|
|
2269
|
+
Advanced: If the JMESPath expression returns a valid Phoenix role name (ADMIN, MEMBER, VIEWER)
|
|
2270
|
+
directly, ROLE_MAPPING is optional - the value will be used as-is after case-insensitive validation.
|
|
2271
|
+
|
|
2272
|
+
⚠️ Role Update Behavior:
|
|
2273
|
+
• When ROLE_ATTRIBUTE_PATH IS configured: User roles are synchronized from the IDP on EVERY login.
|
|
2274
|
+
This ensures Phoenix roles stay in sync with your IDP's role assignments.
|
|
2275
|
+
• When ROLE_ATTRIBUTE_PATH is NOT configured: User roles are preserved as-is (backward compatibility).
|
|
2276
|
+
New users get VIEWER role (least privilege), existing users keep their current roles.
|
|
2277
|
+
|
|
2278
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_ROLE_MAPPING: Maps identity provider role values to Phoenix roles.
|
|
2279
|
+
Format: "IdpRole1:PhoenixRole1,IdpRole2:PhoenixRole2"
|
|
2280
|
+
|
|
2281
|
+
Phoenix roles (case-insensitive):
|
|
2282
|
+
• ADMIN: Full system access, can manage users and settings
|
|
2283
|
+
• MEMBER: Standard user access, can create and manage own resources
|
|
2284
|
+
• VIEWER: Read-only access, cannot create or modify resources
|
|
2285
|
+
|
|
2286
|
+
Example mappings:
|
|
2287
|
+
PHOENIX_OAUTH2_OKTA_ROLE_MAPPING="Owner:ADMIN,Developer:MEMBER,Guest:VIEWER"
|
|
2288
|
+
PHOENIX_OAUTH2_KEYCLOAK_ROLE_MAPPING="admin:ADMIN,user:MEMBER"
|
|
2289
|
+
|
|
2290
|
+
⚠️ Security: The SYSTEM role cannot be assigned via OAuth2. Attempts to map to SYSTEM will be rejected.
|
|
2291
|
+
|
|
2292
|
+
Optional Behavior (no mapping required):
|
|
2293
|
+
If ROLE_MAPPING is not configured but ROLE_ATTRIBUTE_PATH is set, the system will use the
|
|
2294
|
+
IDP role value directly if it exactly matches "ADMIN", "MEMBER", or "VIEWER" (case-insensitive).
|
|
2295
|
+
This allows IDPs that already use Phoenix's role names to work without explicit mapping.
|
|
2296
|
+
|
|
2297
|
+
IDP role keys are case-sensitive and must match exactly. Phoenix role values are case-insensitive
|
|
2298
|
+
but will be normalized to uppercase (ADMIN, MEMBER, VIEWER). If a user's IDP role is not in the
|
|
2299
|
+
mapping, behavior depends on ROLE_ATTRIBUTE_STRICT:
|
|
2300
|
+
• strict=false (default): User gets VIEWER role (least privilege)
|
|
2301
|
+
• strict=true: User is denied access
|
|
2302
|
+
|
|
2303
|
+
Works together with ROLE_ATTRIBUTE_PATH. If ROLE_ATTRIBUTE_PATH is set but ROLE_MAPPING is not,
|
|
2304
|
+
the IDP role value is used directly if it matches a valid Phoenix role (ADMIN, MEMBER, VIEWER).
|
|
2305
|
+
If the IDP role doesn't match a valid Phoenix role, behavior depends on ROLE_ATTRIBUTE_STRICT.
|
|
2306
|
+
|
|
2307
|
+
- PHOENIX_OAUTH2_{IDP_NAME}_ROLE_ATTRIBUTE_STRICT: Controls behavior when role cannot be determined
|
|
2308
|
+
from identity provider claims. Defaults to false.
|
|
2309
|
+
|
|
2310
|
+
When true:
|
|
2311
|
+
• Missing role claim → access denied
|
|
2312
|
+
• Role not in ROLE_MAPPING → access denied
|
|
2313
|
+
• Empty/invalid role value → access denied
|
|
2314
|
+
|
|
2315
|
+
When false (default):
|
|
2316
|
+
• Missing/unmapped/invalid role → user gets VIEWER role (least privilege, fail-safe)
|
|
2317
|
+
|
|
2318
|
+
Strict mode is recommended for high-security environments where all users must have explicitly
|
|
2319
|
+
assigned roles. Non-strict mode (default) is more forgiving and suitable for gradual rollout
|
|
2320
|
+
of role mapping.
|
|
2321
|
+
|
|
2322
|
+
Example:
|
|
2323
|
+
PHOENIX_OAUTH2_OKTA_ROLE_ATTRIBUTE_STRICT=true
|
|
2324
|
+
|
|
2325
|
+
Multiple Identity Providers:
|
|
2326
|
+
You can configure multiple IDPs simultaneously. Users will see all configured providers
|
|
2327
|
+
as login options. Each IDP is configured independently with its own set of variables.
|
|
2328
|
+
|
|
2329
|
+
Group-based access control and role mapping are evaluated per-provider:
|
|
2330
|
+
• Groups control access (who can sign in): Users must belong to ALLOWED_GROUPS
|
|
2331
|
+
• Roles control permissions (what users can do): Users are assigned Phoenix roles via ROLE_MAPPING
|
|
2332
|
+
• Groups are checked first, then roles are assigned if access is granted
|
|
2333
|
+
• Each IDP can have different group/role configurations
|
|
1029
2334
|
|
|
1030
2335
|
Returns:
|
|
1031
2336
|
list[OAuth2ClientConfig]: A list of configured OAuth2 identity providers, sorted alphabetically by IDP name.
|
|
@@ -1035,20 +2340,82 @@ def get_env_oauth2_settings() -> list[OAuth2ClientConfig]:
|
|
|
1035
2340
|
ValueError: If required environment variables are missing or invalid.
|
|
1036
2341
|
Specifically, if the OIDC configuration URL is not HTTPS (except for localhost).
|
|
1037
2342
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
2343
|
+
Examples:
|
|
2344
|
+
Basic configuration with Google:
|
|
2345
|
+
PHOENIX_OAUTH2_GOOGLE_CLIENT_ID=your_client_id
|
|
2346
|
+
PHOENIX_OAUTH2_GOOGLE_CLIENT_SECRET=your_client_secret
|
|
2347
|
+
PHOENIX_OAUTH2_GOOGLE_OIDC_CONFIG_URL=https://accounts.google.com/.well-known/openid-configuration
|
|
2348
|
+
|
|
2349
|
+
With custom display name and auto-login:
|
|
2350
|
+
PHOENIX_OAUTH2_GOOGLE_DISPLAY_NAME=Google Workspace
|
|
2351
|
+
PHOENIX_OAUTH2_GOOGLE_AUTO_LOGIN=true
|
|
2352
|
+
|
|
2353
|
+
With group-based access control (simple path):
|
|
2354
|
+
PHOENIX_OAUTH2_GOOGLE_GROUPS_ATTRIBUTE_PATH=groups
|
|
2355
|
+
PHOENIX_OAUTH2_GOOGLE_ALLOWED_GROUPS=engineering platform-team
|
|
2356
|
+
|
|
2357
|
+
With nested group path (Keycloak):
|
|
2358
|
+
PHOENIX_OAUTH2_KEYCLOAK_GROUPS_ATTRIBUTE_PATH=resource_access.phoenix.roles
|
|
2359
|
+
PHOENIX_OAUTH2_KEYCLOAK_ALLOWED_GROUPS=admin developer
|
|
2360
|
+
|
|
2361
|
+
With special characters in path (AWS Cognito - quotes REQUIRED):
|
|
2362
|
+
PHOENIX_OAUTH2_COGNITO_GROUPS_ATTRIBUTE_PATH='"cognito:groups"'
|
|
2363
|
+
PHOENIX_OAUTH2_COGNITO_ALLOWED_GROUPS=Administrators PowerUsers
|
|
2364
|
+
|
|
2365
|
+
With namespaced claims (Auth0 - quotes REQUIRED):
|
|
2366
|
+
PHOENIX_OAUTH2_AUTH0_GROUPS_ATTRIBUTE_PATH='"https://myapp.com/groups"'
|
|
2367
|
+
PHOENIX_OAUTH2_AUTH0_ALLOWED_GROUPS=admin users
|
|
2368
|
+
|
|
2369
|
+
With array projection (extract names from objects):
|
|
2370
|
+
PHOENIX_OAUTH2_CUSTOM_GROUPS_ATTRIBUTE_PATH=teams[*].name
|
|
2371
|
+
PHOENIX_OAUTH2_CUSTOM_ALLOWED_GROUPS=engineering operations
|
|
2372
|
+
|
|
2373
|
+
With role mapping (simple):
|
|
2374
|
+
PHOENIX_OAUTH2_OKTA_ROLE_ATTRIBUTE_PATH=role
|
|
2375
|
+
PHOENIX_OAUTH2_OKTA_ROLE_MAPPING="Owner:ADMIN,Developer:MEMBER,Viewer:VIEWER"
|
|
2376
|
+
|
|
2377
|
+
With role mapping (nested path for Keycloak):
|
|
2378
|
+
PHOENIX_OAUTH2_KEYCLOAK_ROLE_ATTRIBUTE_PATH=resource_access.phoenix.role
|
|
2379
|
+
PHOENIX_OAUTH2_KEYCLOAK_ROLE_MAPPING="admin:ADMIN,user:MEMBER"
|
|
2380
|
+
|
|
2381
|
+
With role mapping in strict mode (deny unmapped roles):
|
|
2382
|
+
PHOENIX_OAUTH2_OKTA_ROLE_ATTRIBUTE_PATH=role
|
|
2383
|
+
PHOENIX_OAUTH2_OKTA_ROLE_MAPPING="Owner:ADMIN,Developer:MEMBER"
|
|
2384
|
+
PHOENIX_OAUTH2_OKTA_ROLE_ATTRIBUTE_STRICT=true
|
|
2385
|
+
|
|
2386
|
+
With conditional logic to compute role from groups (no mapping needed):
|
|
2387
|
+
PHOENIX_OAUTH2_OKTA_ROLE_ATTRIBUTE_PATH="contains(groups[*], 'admin') && 'ADMIN' || contains(groups[*], 'editor') && 'MEMBER' || 'VIEWER'"
|
|
2388
|
+
|
|
2389
|
+
With both groups and roles (groups control access, roles control permissions):
|
|
2390
|
+
PHOENIX_OAUTH2_OKTA_GROUPS_ATTRIBUTE_PATH=groups
|
|
2391
|
+
PHOENIX_OAUTH2_OKTA_ALLOWED_GROUPS=engineering platform-team
|
|
2392
|
+
PHOENIX_OAUTH2_OKTA_ROLE_ATTRIBUTE_PATH=role
|
|
2393
|
+
PHOENIX_OAUTH2_OKTA_ROLE_MAPPING="Owner:ADMIN,Developer:MEMBER,Guest:VIEWER"
|
|
2394
|
+
|
|
2395
|
+
For public clients using PKCE (no client secret needed):
|
|
2396
|
+
PHOENIX_OAUTH2_MOBILE_CLIENT_ID=mobile_app_id
|
|
2397
|
+
PHOENIX_OAUTH2_MOBILE_OIDC_CONFIG_URL=https://auth.example.com/.well-known/openid-configuration
|
|
2398
|
+
PHOENIX_OAUTH2_MOBILE_TOKEN_ENDPOINT_AUTH_METHOD=none
|
|
2399
|
+
PHOENIX_OAUTH2_MOBILE_USE_PKCE=true
|
|
2400
|
+
|
|
2401
|
+
Multiple identity providers (users can choose):
|
|
2402
|
+
# Google OAuth
|
|
2403
|
+
PHOENIX_OAUTH2_GOOGLE_CLIENT_ID=google_client_id
|
|
2404
|
+
PHOENIX_OAUTH2_GOOGLE_CLIENT_SECRET=google_secret
|
|
2405
|
+
PHOENIX_OAUTH2_GOOGLE_OIDC_CONFIG_URL=https://accounts.google.com/.well-known/openid-configuration
|
|
2406
|
+
|
|
2407
|
+
# Internal Okta
|
|
2408
|
+
PHOENIX_OAUTH2_OKTA_CLIENT_ID=okta_client_id
|
|
2409
|
+
PHOENIX_OAUTH2_OKTA_CLIENT_SECRET=okta_secret
|
|
2410
|
+
PHOENIX_OAUTH2_OKTA_OIDC_CONFIG_URL=https://your-domain.okta.com/.well-known/openid-configuration
|
|
2411
|
+
PHOENIX_OAUTH2_OKTA_GROUPS_ATTRIBUTE_PATH=groups
|
|
2412
|
+
PHOENIX_OAUTH2_OKTA_ALLOWED_GROUPS=engineering
|
|
1045
2413
|
""" # noqa: E501
|
|
1046
2414
|
idp_names = set()
|
|
1047
|
-
pattern = re.compile(
|
|
1048
|
-
r"^PHOENIX_OAUTH2_(\w+)_(DISPLAY_NAME|CLIENT_ID|CLIENT_SECRET|OIDC_CONFIG_URL|ALLOW_SIGN_UP|AUTO_LOGIN)$" # noqa: E501
|
|
1049
|
-
)
|
|
1050
2415
|
for env_var in os.environ:
|
|
1051
|
-
if (match :=
|
|
2416
|
+
if (match := _OAUTH2_ENV_VAR_PATTERN.match(env_var)) is not None and (
|
|
2417
|
+
idp_name := match.group(1).lower()
|
|
2418
|
+
):
|
|
1052
2419
|
idp_names.add(idp_name)
|
|
1053
2420
|
return [OAuth2ClientConfig.from_env(idp_name) for idp_name in sorted(idp_names)]
|
|
1054
2421
|
|
|
@@ -1123,32 +2490,40 @@ class DirectoryError(Exception):
|
|
|
1123
2490
|
super().__init__(message)
|
|
1124
2491
|
|
|
1125
2492
|
|
|
1126
|
-
# LEGACY: Regex for backward compatibility with host:port parsing in PHOENIX_POSTGRES_HOST
|
|
1127
|
-
_HOST_PORT_REGEX = re.compile(r"^[^:]+:\d{1,5}$")
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
2493
|
def get_env_postgres_connection_str() -> Optional[str]:
|
|
1131
2494
|
"""
|
|
1132
2495
|
Build PostgreSQL connection string from environment variables.
|
|
2496
|
+
"""
|
|
2497
|
+
pg_host = getenv(ENV_PHOENIX_POSTGRES_HOST, "").rstrip("/")
|
|
2498
|
+
pg_user = getenv(ENV_PHOENIX_POSTGRES_USER)
|
|
2499
|
+
pg_password = getenv(ENV_PHOENIX_POSTGRES_PASSWORD)
|
|
2500
|
+
use_iam_auth = _bool_val(ENV_PHOENIX_POSTGRES_USE_AWS_IAM_AUTH, False)
|
|
1133
2501
|
|
|
1134
|
-
|
|
1135
|
-
""" # noqa: E501
|
|
1136
|
-
if not (
|
|
1137
|
-
(pg_host := getenv(ENV_PHOENIX_POSTGRES_HOST, "").rstrip("/"))
|
|
1138
|
-
and (pg_user := getenv(ENV_PHOENIX_POSTGRES_USER))
|
|
1139
|
-
and (pg_password := getenv(ENV_PHOENIX_POSTGRES_PASSWORD))
|
|
1140
|
-
):
|
|
2502
|
+
if not (pg_host and pg_user):
|
|
1141
2503
|
return None
|
|
2504
|
+
|
|
2505
|
+
if use_iam_auth:
|
|
2506
|
+
if pg_password:
|
|
2507
|
+
raise ValueError(
|
|
2508
|
+
f"The environment variable {ENV_PHOENIX_POSTGRES_PASSWORD} is set but will be "
|
|
2509
|
+
"ignored when using AWS RDS IAM authentication "
|
|
2510
|
+
f"({ENV_PHOENIX_POSTGRES_USE_AWS_IAM_AUTH}=true). Authentication tokens will be "
|
|
2511
|
+
"generated using AWS credentials."
|
|
2512
|
+
)
|
|
2513
|
+
connection_str = f"postgresql://{quote(pg_user)}@{pg_host}"
|
|
2514
|
+
else:
|
|
2515
|
+
if not pg_password:
|
|
2516
|
+
raise ValueError(
|
|
2517
|
+
f"The environment variable {ENV_PHOENIX_POSTGRES_PASSWORD} is not set. "
|
|
2518
|
+
"Please set it to the password for the PostgreSQL database."
|
|
2519
|
+
)
|
|
2520
|
+
encoded_user = quote(pg_user)
|
|
2521
|
+
encoded_password = quote(pg_password)
|
|
2522
|
+
connection_str = f"postgresql://{encoded_user}:{encoded_password}@{pg_host}"
|
|
2523
|
+
|
|
1142
2524
|
pg_port = getenv(ENV_PHOENIX_POSTGRES_PORT)
|
|
1143
2525
|
pg_db = getenv(ENV_PHOENIX_POSTGRES_DB)
|
|
1144
2526
|
|
|
1145
|
-
if _HOST_PORT_REGEX.match(pg_host): # maintain backward compatibility
|
|
1146
|
-
pg_host, parsed_port = pg_host.split(":")
|
|
1147
|
-
pg_port = pg_port or parsed_port # use the explicitly set port if provided
|
|
1148
|
-
|
|
1149
|
-
encoded_user = quote(pg_user)
|
|
1150
|
-
encoded_password = quote(pg_password)
|
|
1151
|
-
connection_str = f"postgresql://{encoded_user}:{encoded_password}@{pg_host}"
|
|
1152
2527
|
if pg_port:
|
|
1153
2528
|
connection_str = f"{connection_str}:{pg_port}"
|
|
1154
2529
|
if pg_db:
|
|
@@ -1417,6 +2792,30 @@ def get_env_enable_prometheus() -> bool:
|
|
|
1417
2792
|
)
|
|
1418
2793
|
|
|
1419
2794
|
|
|
2795
|
+
def get_env_max_spans_queue_size() -> int:
|
|
2796
|
+
"""
|
|
2797
|
+
Gets the maximum spans queue size from the PHOENIX_MAX_SPANS_QUEUE_SIZE environment variable.
|
|
2798
|
+
|
|
2799
|
+
Returns:
|
|
2800
|
+
int: The maximum number of spans to hold in queue before rejecting requests.
|
|
2801
|
+
Defaults to 20,000 if not set.
|
|
2802
|
+
|
|
2803
|
+
Raises:
|
|
2804
|
+
ValueError: If the value is not a positive integer.
|
|
2805
|
+
|
|
2806
|
+
Note:
|
|
2807
|
+
The actual queue size may exceed this limit due to batch processing where a single
|
|
2808
|
+
accepted request can contain multiple spans. This is a heuristic for memory protection.
|
|
2809
|
+
"""
|
|
2810
|
+
max_size = _int_val(ENV_PHOENIX_MAX_SPANS_QUEUE_SIZE, 20_000)
|
|
2811
|
+
if max_size <= 0:
|
|
2812
|
+
raise ValueError(
|
|
2813
|
+
f"Invalid value for environment variable {ENV_PHOENIX_MAX_SPANS_QUEUE_SIZE}: "
|
|
2814
|
+
f"{max_size}. Value must be a positive integer."
|
|
2815
|
+
)
|
|
2816
|
+
return max_size
|
|
2817
|
+
|
|
2818
|
+
|
|
1420
2819
|
def get_env_client_headers() -> dict[str, str]:
|
|
1421
2820
|
headers = parse_env_headers(getenv(ENV_PHOENIX_CLIENT_HEADERS))
|
|
1422
2821
|
if (api_key := get_env_phoenix_api_key()) and "authorization" not in [
|
|
@@ -1610,6 +3009,10 @@ def get_env_disable_migrations() -> bool:
|
|
|
1610
3009
|
return _bool_val(ENV_PHOENIX_DANGEROUSLY_DISABLE_MIGRATIONS, False)
|
|
1611
3010
|
|
|
1612
3011
|
|
|
3012
|
+
def get_env_mask_internal_server_errors() -> bool:
|
|
3013
|
+
return _bool_val(ENV_PHOENIX_MASK_INTERNAL_SERVER_ERRORS, True)
|
|
3014
|
+
|
|
3015
|
+
|
|
1613
3016
|
DEFAULT_PROJECT_NAME = "default"
|
|
1614
3017
|
_KUBERNETES_PHOENIX_PORT_PATTERN = re.compile(r"^tcp://\d{1,3}[.]\d{1,3}[.]\d{1,3}[.]\d{1,3}:\d+$")
|
|
1615
3018
|
|
|
@@ -1625,16 +3028,49 @@ def get_env_allowed_origins() -> Optional[list[str]]:
|
|
|
1625
3028
|
return allowed_origins.split(",")
|
|
1626
3029
|
|
|
1627
3030
|
|
|
3031
|
+
def get_env_telemetry_enabled() -> bool:
|
|
3032
|
+
"""
|
|
3033
|
+
Gets whether telemetry is enabled from the PHOENIX_TELEMETRY_ENABLED environment variable.
|
|
3034
|
+
|
|
3035
|
+
When set to False, disables both FullStory and Scarf.sh tracking regardless of their
|
|
3036
|
+
individual environment variable settings.
|
|
3037
|
+
|
|
3038
|
+
Returns False if external resources are disallowed.
|
|
3039
|
+
|
|
3040
|
+
Returns:
|
|
3041
|
+
bool: True if telemetry is enabled (default), False otherwise.
|
|
3042
|
+
"""
|
|
3043
|
+
if not get_env_allow_external_resources():
|
|
3044
|
+
return False
|
|
3045
|
+
return _bool_val(ENV_PHOENIX_TELEMETRY_ENABLED, True)
|
|
3046
|
+
|
|
3047
|
+
|
|
1628
3048
|
def get_env_fullstory_org() -> Optional[str]:
|
|
1629
3049
|
"""
|
|
1630
3050
|
Get the FullStory organization ID from environment variables.
|
|
1631
3051
|
|
|
1632
3052
|
Returns:
|
|
1633
|
-
Optional[str]: The FullStory organization ID if set
|
|
3053
|
+
Optional[str]: The FullStory organization ID if set and telemetry is enabled,
|
|
3054
|
+
None otherwise.
|
|
1634
3055
|
"""
|
|
3056
|
+
if not get_env_telemetry_enabled():
|
|
3057
|
+
return None
|
|
1635
3058
|
return getenv(ENV_PHOENIX_FULLSTORY_ORG)
|
|
1636
3059
|
|
|
1637
3060
|
|
|
3061
|
+
def get_env_scarf_sh_pixel_id() -> Optional[str]:
|
|
3062
|
+
"""
|
|
3063
|
+
Get the Scarf.sh pixel ID from environment variables.
|
|
3064
|
+
|
|
3065
|
+
Returns:
|
|
3066
|
+
Optional[str]: The Scarf.sh pixel ID if set and telemetry is enabled, None otherwise.
|
|
3067
|
+
"""
|
|
3068
|
+
if not get_env_telemetry_enabled():
|
|
3069
|
+
return None
|
|
3070
|
+
# Return the phoenix-app-v12 pixel
|
|
3071
|
+
return getenv(ENV_PHOENIX_SCARF_SH_PIXEL_ID) or "98877b05-7d80-493e-ab95-97c104785d1e"
|
|
3072
|
+
|
|
3073
|
+
|
|
1638
3074
|
def get_env_management_url() -> Optional[str]:
|
|
1639
3075
|
"""
|
|
1640
3076
|
Gets the value of the PHOENIX_MANAGEMENT_URL environment variable.
|
|
@@ -1675,7 +3111,9 @@ def verify_server_environment_variables() -> None:
|
|
|
1675
3111
|
get_env_database_allocated_storage_capacity_gibibytes()
|
|
1676
3112
|
get_env_database_usage_email_warning_threshold_percentage()
|
|
1677
3113
|
get_env_database_usage_insertion_blocking_threshold_percentage()
|
|
3114
|
+
get_env_max_spans_queue_size()
|
|
1678
3115
|
validate_env_support_email()
|
|
3116
|
+
_validate_iam_auth_config()
|
|
1679
3117
|
|
|
1680
3118
|
# Notify users about deprecated environment variables if they are being used.
|
|
1681
3119
|
if os.getenv("PHOENIX_ENABLE_WEBSOCKETS") is not None:
|
|
@@ -1734,3 +3172,78 @@ def get_env_allow_external_resources() -> bool:
|
|
|
1734
3172
|
Defaults to True if not set.
|
|
1735
3173
|
"""
|
|
1736
3174
|
return _bool_val(ENV_PHOENIX_ALLOW_EXTERNAL_RESOURCES, True)
|
|
3175
|
+
|
|
3176
|
+
|
|
3177
|
+
def get_env_postgres_use_iam_auth() -> bool:
|
|
3178
|
+
"""
|
|
3179
|
+
Gets whether AWS RDS IAM authentication is enabled for PostgreSQL connections.
|
|
3180
|
+
|
|
3181
|
+
Returns:
|
|
3182
|
+
bool: True if IAM authentication should be used, False otherwise (default)
|
|
3183
|
+
"""
|
|
3184
|
+
return _bool_val(ENV_PHOENIX_POSTGRES_USE_AWS_IAM_AUTH, False)
|
|
3185
|
+
|
|
3186
|
+
|
|
3187
|
+
def get_env_postgres_iam_token_lifetime() -> int:
|
|
3188
|
+
"""
|
|
3189
|
+
Gets the token lifetime in seconds for AWS RDS IAM authentication pool recycling.
|
|
3190
|
+
|
|
3191
|
+
AWS RDS IAM tokens are valid for 15 minutes (900 seconds). This value should be
|
|
3192
|
+
set slightly lower to ensure connections are recycled before token expiration.
|
|
3193
|
+
|
|
3194
|
+
Returns:
|
|
3195
|
+
int: Token lifetime in seconds (default: 840 = 14 minutes)
|
|
3196
|
+
"""
|
|
3197
|
+
lifetime = _int_val(ENV_PHOENIX_POSTGRES_AWS_IAM_TOKEN_LIFETIME_SECONDS, 840)
|
|
3198
|
+
if lifetime <= 0:
|
|
3199
|
+
raise ValueError(
|
|
3200
|
+
f"{ENV_PHOENIX_POSTGRES_AWS_IAM_TOKEN_LIFETIME_SECONDS} must be a positive integer. "
|
|
3201
|
+
f"Got: {lifetime}"
|
|
3202
|
+
)
|
|
3203
|
+
if lifetime > 900:
|
|
3204
|
+
logger.warning(
|
|
3205
|
+
f"{ENV_PHOENIX_POSTGRES_AWS_IAM_TOKEN_LIFETIME_SECONDS} is set to {lifetime} seconds, "
|
|
3206
|
+
f"which exceeds AWS RDS IAM token validity (900 seconds / 15 minutes). "
|
|
3207
|
+
f"Consider setting it to 840 seconds (14 minutes) or less."
|
|
3208
|
+
)
|
|
3209
|
+
return lifetime
|
|
3210
|
+
|
|
3211
|
+
|
|
3212
|
+
def _validate_iam_auth_config() -> None:
|
|
3213
|
+
"""
|
|
3214
|
+
Validate AWS RDS IAM authentication configuration if enabled.
|
|
3215
|
+
|
|
3216
|
+
Raises:
|
|
3217
|
+
ImportError: If boto3 is not installed when IAM auth is enabled
|
|
3218
|
+
ValueError: If configuration is invalid
|
|
3219
|
+
"""
|
|
3220
|
+
if not get_env_postgres_use_iam_auth():
|
|
3221
|
+
return
|
|
3222
|
+
|
|
3223
|
+
pg_host = getenv(ENV_PHOENIX_POSTGRES_HOST)
|
|
3224
|
+
if not pg_host:
|
|
3225
|
+
return
|
|
3226
|
+
|
|
3227
|
+
try:
|
|
3228
|
+
import boto3 # type: ignore # noqa: F401
|
|
3229
|
+
except ImportError:
|
|
3230
|
+
raise ImportError(
|
|
3231
|
+
f"boto3 is required when {ENV_PHOENIX_POSTGRES_USE_AWS_IAM_AUTH} is enabled. "
|
|
3232
|
+
"Install it with: pip install 'arize-phoenix[aws]'"
|
|
3233
|
+
)
|
|
3234
|
+
|
|
3235
|
+
if not getenv(ENV_PHOENIX_POSTGRES_USER):
|
|
3236
|
+
raise ValueError(
|
|
3237
|
+
f"{ENV_PHOENIX_POSTGRES_USER} must be set when using AWS RDS IAM authentication"
|
|
3238
|
+
)
|
|
3239
|
+
|
|
3240
|
+
try:
|
|
3241
|
+
client = boto3.client("sts") # pyright: ignore
|
|
3242
|
+
client.get_caller_identity() # pyright: ignore
|
|
3243
|
+
logger.info("✓ AWS credentials validated for RDS IAM authentication")
|
|
3244
|
+
except Exception as e:
|
|
3245
|
+
raise ValueError(
|
|
3246
|
+
f"Failed to validate AWS credentials for RDS IAM authentication: {e}. "
|
|
3247
|
+
"Ensure AWS credentials are configured via environment variables, "
|
|
3248
|
+
"~/.aws/credentials, or IAM role."
|
|
3249
|
+
)
|