arize-phoenix 12.3.0__py3-none-any.whl → 12.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arize-phoenix might be problematic. Click here for more details.
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/METADATA +2 -1
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/RECORD +37 -37
- phoenix/auth.py +19 -0
- phoenix/config.py +302 -53
- phoenix/db/README.md +546 -28
- phoenix/server/api/routers/auth.py +21 -30
- phoenix/server/api/routers/oauth2.py +213 -24
- phoenix/server/api/routers/v1/__init__.py +2 -3
- phoenix/server/api/routers/v1/annotation_configs.py +12 -29
- phoenix/server/api/routers/v1/annotations.py +21 -22
- phoenix/server/api/routers/v1/datasets.py +38 -56
- phoenix/server/api/routers/v1/documents.py +2 -3
- phoenix/server/api/routers/v1/evaluations.py +12 -24
- phoenix/server/api/routers/v1/experiment_evaluations.py +2 -3
- phoenix/server/api/routers/v1/experiment_runs.py +9 -10
- phoenix/server/api/routers/v1/experiments.py +16 -17
- phoenix/server/api/routers/v1/projects.py +15 -21
- phoenix/server/api/routers/v1/prompts.py +30 -31
- phoenix/server/api/routers/v1/sessions.py +2 -5
- phoenix/server/api/routers/v1/spans.py +35 -26
- phoenix/server/api/routers/v1/traces.py +11 -19
- phoenix/server/api/routers/v1/users.py +14 -23
- phoenix/server/api/routers/v1/utils.py +3 -7
- phoenix/server/app.py +1 -2
- phoenix/server/authorization.py +2 -3
- phoenix/server/bearer_auth.py +4 -5
- phoenix/server/oauth2.py +172 -5
- phoenix/server/static/.vite/manifest.json +9 -9
- phoenix/server/static/assets/{components-Bs8eJEpU.js → components-BvsExS75.js} +110 -120
- phoenix/server/static/assets/{index-C6WEu5UP.js → index-iq8WDxat.js} +1 -1
- phoenix/server/static/assets/{pages-D-n2pkoG.js → pages-Ckg4SLQ9.js} +4 -4
- phoenix/trace/attributes.py +80 -13
- phoenix/version.py +1 -1
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/IP_NOTICE +0 -0
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/LICENSE +0 -0
phoenix/server/authorization.py
CHANGED
|
@@ -23,7 +23,6 @@ Usage:
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
from fastapi import HTTPException, Request
|
|
26
|
-
from fastapi import status as fastapi_status
|
|
27
26
|
|
|
28
27
|
from phoenix.config import get_env_support_email
|
|
29
28
|
from phoenix.server.bearer_auth import PhoenixUser
|
|
@@ -49,7 +48,7 @@ def require_admin(request: Request) -> None:
|
|
|
49
48
|
# System users have all privileges
|
|
50
49
|
if not (isinstance(user, PhoenixUser) and user.is_admin):
|
|
51
50
|
raise HTTPException(
|
|
52
|
-
status_code=
|
|
51
|
+
status_code=403,
|
|
53
52
|
detail="Only admin or system users can perform this action.",
|
|
54
53
|
)
|
|
55
54
|
|
|
@@ -82,6 +81,6 @@ def is_not_locked(request: Request) -> None:
|
|
|
82
81
|
if support_email := get_env_support_email():
|
|
83
82
|
detail += f" Need help? Contact us at {support_email}"
|
|
84
83
|
raise HTTPException(
|
|
85
|
-
status_code=
|
|
84
|
+
status_code=507,
|
|
86
85
|
detail=detail,
|
|
87
86
|
)
|
phoenix/server/bearer_auth.py
CHANGED
|
@@ -9,7 +9,6 @@ from fastapi import HTTPException, Request, WebSocket, WebSocketException
|
|
|
9
9
|
from grpc_interceptor import AsyncServerInterceptor
|
|
10
10
|
from starlette.authentication import AuthCredentials, AuthenticationBackend, BaseUser
|
|
11
11
|
from starlette.requests import HTTPConnection
|
|
12
|
-
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
13
12
|
from typing_extensions import override
|
|
14
13
|
|
|
15
14
|
from phoenix import config
|
|
@@ -144,16 +143,16 @@ async def is_authenticated(
|
|
|
144
143
|
"""
|
|
145
144
|
assert request or websocket
|
|
146
145
|
if request and not isinstance((user := request.user), PhoenixUser):
|
|
147
|
-
raise HTTPException(status_code=
|
|
146
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
148
147
|
if websocket and not isinstance((user := websocket.user), PhoenixUser):
|
|
149
|
-
raise WebSocketException(code=
|
|
148
|
+
raise WebSocketException(code=401, reason="Invalid token")
|
|
150
149
|
if isinstance(user, PhoenixSystemUser):
|
|
151
150
|
return
|
|
152
151
|
claims = user.claims
|
|
153
152
|
if claims.status is ClaimSetStatus.EXPIRED:
|
|
154
|
-
raise HTTPException(status_code=
|
|
153
|
+
raise HTTPException(status_code=401, detail="Expired token")
|
|
155
154
|
if claims.status is not ClaimSetStatus.VALID:
|
|
156
|
-
raise HTTPException(status_code=
|
|
155
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
157
156
|
|
|
158
157
|
|
|
159
158
|
async def create_access_and_refresh_tokens(
|
phoenix/server/oauth2.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from collections.abc import Iterable
|
|
2
2
|
from typing import Any, Iterator, Optional
|
|
3
3
|
|
|
4
|
+
import jmespath
|
|
4
5
|
from authlib.integrations.base_client import BaseApp
|
|
5
6
|
from authlib.integrations.base_client.async_app import AsyncOAuth2Mixin
|
|
6
7
|
from authlib.integrations.base_client.async_openid import AsyncOpenIDMixin
|
|
@@ -25,13 +26,58 @@ class OAuth2Client(AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): # type:ignore[
|
|
|
25
26
|
display_name: str,
|
|
26
27
|
allow_sign_up: bool,
|
|
27
28
|
auto_login: bool,
|
|
29
|
+
use_pkce: bool = False,
|
|
30
|
+
groups_attribute_path: Optional[str] = None,
|
|
31
|
+
allowed_groups: Optional[list[str]] = None,
|
|
28
32
|
**kwargs: Any,
|
|
29
33
|
) -> None:
|
|
30
34
|
self._display_name = display_name
|
|
31
35
|
self._allow_sign_up = allow_sign_up
|
|
32
36
|
self._auto_login = auto_login
|
|
37
|
+
self._use_pkce = use_pkce
|
|
38
|
+
|
|
39
|
+
self._groups_attribute_path = (
|
|
40
|
+
groups_attribute_path.strip()
|
|
41
|
+
if groups_attribute_path and groups_attribute_path.strip()
|
|
42
|
+
else None
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if allowed_groups:
|
|
46
|
+
self._allowed_groups = {g for g in allowed_groups if g.strip()}
|
|
47
|
+
else:
|
|
48
|
+
self._allowed_groups = set()
|
|
49
|
+
|
|
50
|
+
if self._allowed_groups and not self._groups_attribute_path:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"groups_attribute_path must be specified when allowed_groups is configured. "
|
|
53
|
+
"Group-based access control requires both parameters to be set."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if self._groups_attribute_path and not self._allowed_groups:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"allowed_groups must be specified when groups_attribute_path is configured. "
|
|
59
|
+
"Group-based access control requires both parameters to be set. "
|
|
60
|
+
"If you don't need group-based access control, remove groups_attribute_path."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self._compiled_groups_path = self._compile_jmespath_expression(self._groups_attribute_path)
|
|
33
64
|
super().__init__(framework=None, *args, **kwargs)
|
|
34
|
-
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _compile_jmespath_expression(path: Optional[str]) -> Optional[jmespath.parser.ParsedResult]:
|
|
68
|
+
"""Validate and compile JMESPath expression at startup for fail-fast behavior."""
|
|
69
|
+
if not path:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
return jmespath.compile(path)
|
|
74
|
+
except (jmespath.exceptions.JMESPathError, jmespath.exceptions.ParseError) as e:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Invalid JMESPath expression in GROUPS_ATTRIBUTE_PATH: '{path}'. Error: {e}. "
|
|
77
|
+
"Hint: Claim keys with special characters (colons, dots, slashes, hyphens) "
|
|
78
|
+
"must be enclosed in double quotes. "
|
|
79
|
+
"Examples: '\"cognito:groups\"', '\"https://myapp.com/groups\"'"
|
|
80
|
+
) from e
|
|
35
81
|
|
|
36
82
|
@property
|
|
37
83
|
def allow_sign_up(self) -> bool:
|
|
@@ -45,6 +91,113 @@ class OAuth2Client(AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): # type:ignore[
|
|
|
45
91
|
def display_name(self) -> str:
|
|
46
92
|
return self._display_name
|
|
47
93
|
|
|
94
|
+
@property
|
|
95
|
+
def use_pkce(self) -> bool:
|
|
96
|
+
return self._use_pkce
|
|
97
|
+
|
|
98
|
+
def has_sufficient_claims(self, claims: dict[str, Any]) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Check if the ID token contains all application-required claims.
|
|
101
|
+
|
|
102
|
+
OIDC Core §2 mandates that ID tokens contain authentication claims (iss, sub, aud,
|
|
103
|
+
exp, iat), but user profile claims (email, name, groups) are optional and may only
|
|
104
|
+
be available via UserInfo endpoint (§5.4, §5.5). This method determines if we need
|
|
105
|
+
to call UserInfo.
|
|
106
|
+
|
|
107
|
+
Application-required claims:
|
|
108
|
+
- email: Required for user identification and account creation
|
|
109
|
+
- groups: Required if group-based access control is configured
|
|
110
|
+
|
|
111
|
+
If any required claim is missing, returns False to trigger UserInfo endpoint call.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
claims: Claims from ID token (OIDC Core §3.1.3.3)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if all application-required claims are present (UserInfo not needed)
|
|
118
|
+
False if additional claims must be fetched from UserInfo endpoint
|
|
119
|
+
"""
|
|
120
|
+
# Check for email claim (required by application)
|
|
121
|
+
email = claims.get("email")
|
|
122
|
+
if not email or not isinstance(email, str) or not email.strip():
|
|
123
|
+
# Email missing or invalid, need UserInfo
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
# Check for group claims if group-based access control is configured
|
|
127
|
+
if self._compiled_groups_path:
|
|
128
|
+
groups = self._extract_groups_from_claims(claims)
|
|
129
|
+
if len(groups) == 0:
|
|
130
|
+
# Groups required but not present, need UserInfo
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
# All required claims present
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def validate_access(self, user_claims: dict[str, Any]) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Validate that the user has access based on configured claim-based access control.
|
|
139
|
+
|
|
140
|
+
Currently supports group-based access control. In the future, this may be extended
|
|
141
|
+
to support organization-based or other claim-based authorization mechanisms.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
user_claims: Claims from the OIDC ID token (OIDC Core §3.1.3.3) or userinfo
|
|
145
|
+
endpoint (OIDC Core §5.3). Custom claims for groups/roles are extracted
|
|
146
|
+
per OIDC Core §5.1.2 (Additional Claims).
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
PermissionError: If user doesn't meet the access requirements
|
|
150
|
+
"""
|
|
151
|
+
if not self._allowed_groups or not self._groups_attribute_path:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
user_groups = self._extract_groups_from_claims(user_claims)
|
|
155
|
+
|
|
156
|
+
if not any(group in self._allowed_groups for group in user_groups):
|
|
157
|
+
raise PermissionError(
|
|
158
|
+
"Access denied. Your account does not belong to any authorized groups."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _extract_groups_from_claims(self, claims: dict[str, Any]) -> list[str]:
|
|
162
|
+
"""Extract group values from claims using the configured JMESPath expression."""
|
|
163
|
+
if not self._compiled_groups_path:
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
result = self._compiled_groups_path.search(claims)
|
|
167
|
+
return self._normalize_to_string_list(result)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _normalize_to_string_list(value: Any) -> list[str]:
|
|
171
|
+
"""
|
|
172
|
+
Normalize a JMESPath result to a list of strings.
|
|
173
|
+
|
|
174
|
+
Handles common OIDC claim formats: single values, lists, and scalar types.
|
|
175
|
+
Non-scalar items (dicts, nested lists) are silently skipped.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
value: Result from JMESPath query
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of string values, or empty list if value cannot be normalized
|
|
182
|
+
"""
|
|
183
|
+
if value is None:
|
|
184
|
+
return []
|
|
185
|
+
|
|
186
|
+
if isinstance(value, str):
|
|
187
|
+
return [value]
|
|
188
|
+
|
|
189
|
+
if isinstance(value, (int, float, bool)):
|
|
190
|
+
return [str(value)]
|
|
191
|
+
|
|
192
|
+
if isinstance(value, list):
|
|
193
|
+
return [
|
|
194
|
+
str(item) if isinstance(item, (int, float, bool)) else item
|
|
195
|
+
for item in value
|
|
196
|
+
if isinstance(item, (str, int, float, bool))
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
return []
|
|
200
|
+
|
|
48
201
|
|
|
49
202
|
class OAuth2Clients:
|
|
50
203
|
def __init__(self) -> None:
|
|
@@ -67,16 +220,30 @@ class OAuth2Clients:
|
|
|
67
220
|
def add_client(self, config: OAuth2ClientConfig) -> None:
|
|
68
221
|
if (idp_name := config.idp_name) in self._clients:
|
|
69
222
|
raise ValueError(f"oauth client already registered: {idp_name}")
|
|
223
|
+
# RFC 6749 §3.3: scope parameter (space-delimited list of scopes)
|
|
224
|
+
client_kwargs = {"scope": config.scopes}
|
|
225
|
+
|
|
226
|
+
if config.token_endpoint_auth_method:
|
|
227
|
+
# OIDC Core §9: Client authentication method at token endpoint
|
|
228
|
+
client_kwargs["token_endpoint_auth_method"] = config.token_endpoint_auth_method
|
|
229
|
+
if config.use_pkce:
|
|
230
|
+
# Always use S256 for PKCE (RFC 7636 §4.2: SHA-256 code challenge method)
|
|
231
|
+
client_kwargs["code_challenge_method"] = "S256"
|
|
232
|
+
|
|
70
233
|
client = OAuth2Client(
|
|
71
234
|
name=config.idp_name,
|
|
72
|
-
client_id=config.client_id,
|
|
73
|
-
client_secret=config.client_secret,
|
|
74
|
-
server_metadata_url=config.oidc_config_url,
|
|
75
|
-
client_kwargs=
|
|
235
|
+
client_id=config.client_id, # RFC 6749 §2.2
|
|
236
|
+
client_secret=config.client_secret, # RFC 6749 §2.3.1
|
|
237
|
+
server_metadata_url=config.oidc_config_url, # OIDC Discovery §4
|
|
238
|
+
client_kwargs=client_kwargs,
|
|
76
239
|
display_name=config.idp_display_name,
|
|
77
240
|
allow_sign_up=config.allow_sign_up,
|
|
78
241
|
auto_login=config.auto_login,
|
|
242
|
+
use_pkce=config.use_pkce,
|
|
243
|
+
groups_attribute_path=config.groups_attribute_path,
|
|
244
|
+
allowed_groups=config.allowed_groups,
|
|
79
245
|
)
|
|
246
|
+
|
|
80
247
|
if config.auto_login:
|
|
81
248
|
if self._auto_login_client:
|
|
82
249
|
raise ValueError("only one auto-login client is allowed")
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_components-
|
|
3
|
-
"file": "assets/components-
|
|
2
|
+
"_components-BvsExS75.js": {
|
|
3
|
+
"file": "assets/components-BvsExS75.js",
|
|
4
4
|
"name": "components",
|
|
5
5
|
"imports": [
|
|
6
6
|
"_vendor-D2eEI-6h.js",
|
|
7
|
-
"_pages-
|
|
7
|
+
"_pages-Ckg4SLQ9.js",
|
|
8
8
|
"_vendor-arizeai-kfOei7nf.js",
|
|
9
9
|
"_vendor-codemirror-1bq_t1Ec.js",
|
|
10
10
|
"_vendor-three-BLWp5bic.js"
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
|
-
"_pages-
|
|
14
|
-
"file": "assets/pages-
|
|
13
|
+
"_pages-Ckg4SLQ9.js": {
|
|
14
|
+
"file": "assets/pages-Ckg4SLQ9.js",
|
|
15
15
|
"name": "pages",
|
|
16
16
|
"imports": [
|
|
17
17
|
"_vendor-D2eEI-6h.js",
|
|
18
|
-
"_components-
|
|
18
|
+
"_components-BvsExS75.js",
|
|
19
19
|
"_vendor-arizeai-kfOei7nf.js",
|
|
20
20
|
"_vendor-codemirror-1bq_t1Ec.js",
|
|
21
21
|
"_vendor-recharts-DQ4xfrf4.js"
|
|
@@ -75,15 +75,15 @@
|
|
|
75
75
|
"name": "vendor-three"
|
|
76
76
|
},
|
|
77
77
|
"index.tsx": {
|
|
78
|
-
"file": "assets/index-
|
|
78
|
+
"file": "assets/index-iq8WDxat.js",
|
|
79
79
|
"name": "index",
|
|
80
80
|
"src": "index.tsx",
|
|
81
81
|
"isEntry": true,
|
|
82
82
|
"imports": [
|
|
83
83
|
"_vendor-D2eEI-6h.js",
|
|
84
84
|
"_vendor-arizeai-kfOei7nf.js",
|
|
85
|
-
"_pages-
|
|
86
|
-
"_components-
|
|
85
|
+
"_pages-Ckg4SLQ9.js",
|
|
86
|
+
"_components-BvsExS75.js",
|
|
87
87
|
"_vendor-three-BLWp5bic.js",
|
|
88
88
|
"_vendor-codemirror-1bq_t1Ec.js",
|
|
89
89
|
"_vendor-shiki-GGmcIQxA.js",
|