oidcauthlib 3.0.20__tar.gz → 3.0.22__tar.gz
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.
- oidcauthlib-3.0.22/PKG-INFO +68 -0
- oidcauthlib-3.0.22/README.md +40 -0
- oidcauthlib-3.0.22/VERSION +1 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/auth_manager.py +7 -1
- oidcauthlib-3.0.22/oidcauthlib/auth/userinfo/oidc_userinfo_manager.py +297 -0
- oidcauthlib-3.0.22/oidcauthlib/open_telemetry/configurator.py +74 -0
- oidcauthlib-3.0.22/oidcauthlib/open_telemetry/filtering_sampler.py +90 -0
- oidcauthlib-3.0.22/oidcauthlib/open_telemetry/filtering_span_exporter.py +145 -0
- oidcauthlib-3.0.22/oidcauthlib/open_telemetry/filtering_span_processor.py +167 -0
- oidcauthlib-3.0.22/oidcauthlib/open_telemetry/otel_setup.py +294 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/open_telemetry/span_names.py +3 -0
- oidcauthlib-3.0.22/oidcauthlib/utilities/logger/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/logger/log_levels.py +1 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/logger/logging_response.py +10 -9
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/logger/logging_transport.py +12 -11
- oidcauthlib-3.0.22/oidcauthlib.egg-info/PKG-INFO +68 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/SOURCES.txt +7 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/requires.txt +2 -1
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/pyproject.toml +7 -3
- oidcauthlib-3.0.20/PKG-INFO +0 -28
- oidcauthlib-3.0.20/README.md +0 -1
- oidcauthlib-3.0.20/VERSION +0 -1
- oidcauthlib-3.0.20/oidcauthlib.egg-info/PKG-INFO +0 -28
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/LICENSE +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/MANIFEST.in +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/auth_helper.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/oauth_cache.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/oauth_memory_cache.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/oauth_mongo_cache.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/config/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/config/auth_config.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/config/auth_config_reader.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/dcr_client.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/dcr_manager.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/dcr_registration.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_bearer_token_expired_exception.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_bearer_token_invalid_exception.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_bearer_token_missing_exception.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_needed_exception.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/fastapi_auth_manager.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/middleware/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/middleware/request_scope_middleware.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/middleware/token_reader_middleware.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/auth.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/base_db_model.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/cache_item.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/client_key_set.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/token.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/base_repository.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/memory/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/memory/memory_repository.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/mongo/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/mongo/mongo_repository.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/repository_factory.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/routers/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/routers/auth_router.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/token_reader.py +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/auth/well_known_configuration → oidcauthlib-3.0.22/oidcauthlib/auth/userinfo}/__init__.py +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/container → oidcauthlib-3.0.22/oidcauthlib/auth/well_known_configuration}/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/auth_server_metadata.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/auth_server_metadata_discovery.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/well_known_configuration_cache.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/well_known_configuration_cache_result.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/well_known_configuration_manager.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/cache_gate/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/cache_gate/advisory_lock.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/cache_gate/version_gate.py +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/open_telemetry → oidcauthlib-3.0.22/oidcauthlib/container}/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/container/oidc_authlib_container_factory.py +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/storage → oidcauthlib-3.0.22/oidcauthlib/open_telemetry}/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/open_telemetry/attribute_names.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/py.typed +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/utilities → oidcauthlib-3.0.22/oidcauthlib/storage}/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/cache_to_collection_mapper.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/memory_storage_factory.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/mongo_gridfs_db.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/mongo_gridfs_exception.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/mongo_storage_factory.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/storage_factory.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/storage_factory_creator.py +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/utilities/environment → oidcauthlib-3.0.22/oidcauthlib/utilities}/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/cached.py +0 -0
- {oidcauthlib-3.0.20/oidcauthlib/utilities/logger → oidcauthlib-3.0.22/oidcauthlib/utilities/environment}/__init__.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/environment/abstract_environment_variables.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/environment/oidc_environment_variables.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/mongo_url_utils.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/url_validator.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/dependency_links.txt +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/top_level.txt +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/setup.cfg +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/tests/test_mongo_url_utils.py +0 -0
- {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/tests/test_simple.py +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oidcauthlib
|
|
3
|
+
Version: 3.0.22
|
|
4
|
+
Summary: oidcauthlib
|
|
5
|
+
Author-email: Imran Qureshi <imran.qureshi@bwell.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/icanbwell/oidc-auth-lib
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: httpx>=0.27.2
|
|
16
|
+
Requires-Dist: httpx2>=2.12.0
|
|
17
|
+
Requires-Dist: authlib>=1.8.0
|
|
18
|
+
Requires-Dist: joserfc>=1.4.3
|
|
19
|
+
Requires-Dist: pydantic<3.0.0,>=2.0
|
|
20
|
+
Requires-Dist: pymongo[snappy]>=4.15.3
|
|
21
|
+
Requires-Dist: python-snappy>=0.7.3
|
|
22
|
+
Requires-Dist: fastapi>=0.115.8
|
|
23
|
+
Requires-Dist: starlette>=0.49.1
|
|
24
|
+
Requires-Dist: py-key-value-aio[memory,mongodb,pydantic,redis]>=0.4.4
|
|
25
|
+
Requires-Dist: opentelemetry-api>=1.39.1
|
|
26
|
+
Requires-Dist: simple-container>=1.0.2
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# oidcauthlib
|
|
30
|
+
|
|
31
|
+
## OIDC userinfo scopes (`OidcUserInfoManager`)
|
|
32
|
+
|
|
33
|
+
Some providers (e.g. Okta employee access tokens) only carry `openid profile email groups` in the JWT;
|
|
34
|
+
the scopes that authorize a caller appear only on the OIDC userinfo response.
|
|
35
|
+
`oidcauthlib.auth.userinfo.oidc_userinfo_manager.OidcUserInfoManager` resolves a token's effective
|
|
36
|
+
scopes from the token's own claims plus the userinfo endpoint, and caches the userinfo-derived scopes in
|
|
37
|
+
a key-value store.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
manager = OidcUserInfoManager(
|
|
41
|
+
auth_config_reader=auth_config_reader,
|
|
42
|
+
well_known_configuration_manager=well_known_configuration_manager,
|
|
43
|
+
store=store, # py-key-value-aio store; yours, not shared with other services
|
|
44
|
+
cache_collection="oidc-user-info-cache",
|
|
45
|
+
cache_ttl_seconds=86400,
|
|
46
|
+
fetch_timeout_seconds=30,
|
|
47
|
+
scope_claims=["custom:scope"], # optional extra claims holding scopes
|
|
48
|
+
group_claims=["groups"], # optional claims holding group names, used whole as scopes
|
|
49
|
+
scope_remove_prefixes=["fhir/prod/"],
|
|
50
|
+
)
|
|
51
|
+
scopes = await manager.get_scopes_from_user_info_async(access_token=token)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Scope derivation** follows fhir-server-ui's `jwtParser`: the `scope` claim (plus `scp`), then
|
|
55
|
+
`scope_claims`, then `group_claims` (each value taken whole). Commas separate like spaces, results are
|
|
56
|
+
de-duplicated, and `scope_remove_prefixes` are stripped (first match wins). Nothing beyond `scope`/`scp`
|
|
57
|
+
is read unless configured.
|
|
58
|
+
- **Provider** is matched to the token's `iss` (exact `issuer`, then `well_known_uri` equal to or beneath the
|
|
59
|
+
issuer at a path boundary), then its client id. A token matching no provider raises `ValueError`; it is
|
|
60
|
+
never sent to another provider.
|
|
61
|
+
- **Cache** key is `oidc-userinfo-scopes:<issuer>:<client_id>:<sub>|v1` with each segment percent-encoded
|
|
62
|
+
(so values containing `:` cannot collide, and two providers never share an entry); only the
|
|
63
|
+
userinfo-derived scopes are stored, never the userinfo response. The token's own scopes are re-derived on every call. A token without
|
|
64
|
+
`sub`, or an empty scope list, is not cached. Changes to a user's entitlements are visible after at most
|
|
65
|
+
`cache_ttl_seconds`.
|
|
66
|
+
- **Failures:** a cache write failure is logged and swallowed. A cache read failure, malformed cache
|
|
67
|
+
entry, userinfo HTTP error or missing provider is raised, so each service decides whether to fail closed
|
|
68
|
+
or degrade.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# oidcauthlib
|
|
2
|
+
|
|
3
|
+
## OIDC userinfo scopes (`OidcUserInfoManager`)
|
|
4
|
+
|
|
5
|
+
Some providers (e.g. Okta employee access tokens) only carry `openid profile email groups` in the JWT;
|
|
6
|
+
the scopes that authorize a caller appear only on the OIDC userinfo response.
|
|
7
|
+
`oidcauthlib.auth.userinfo.oidc_userinfo_manager.OidcUserInfoManager` resolves a token's effective
|
|
8
|
+
scopes from the token's own claims plus the userinfo endpoint, and caches the userinfo-derived scopes in
|
|
9
|
+
a key-value store.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
manager = OidcUserInfoManager(
|
|
13
|
+
auth_config_reader=auth_config_reader,
|
|
14
|
+
well_known_configuration_manager=well_known_configuration_manager,
|
|
15
|
+
store=store, # py-key-value-aio store; yours, not shared with other services
|
|
16
|
+
cache_collection="oidc-user-info-cache",
|
|
17
|
+
cache_ttl_seconds=86400,
|
|
18
|
+
fetch_timeout_seconds=30,
|
|
19
|
+
scope_claims=["custom:scope"], # optional extra claims holding scopes
|
|
20
|
+
group_claims=["groups"], # optional claims holding group names, used whole as scopes
|
|
21
|
+
scope_remove_prefixes=["fhir/prod/"],
|
|
22
|
+
)
|
|
23
|
+
scopes = await manager.get_scopes_from_user_info_async(access_token=token)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **Scope derivation** follows fhir-server-ui's `jwtParser`: the `scope` claim (plus `scp`), then
|
|
27
|
+
`scope_claims`, then `group_claims` (each value taken whole). Commas separate like spaces, results are
|
|
28
|
+
de-duplicated, and `scope_remove_prefixes` are stripped (first match wins). Nothing beyond `scope`/`scp`
|
|
29
|
+
is read unless configured.
|
|
30
|
+
- **Provider** is matched to the token's `iss` (exact `issuer`, then `well_known_uri` equal to or beneath the
|
|
31
|
+
issuer at a path boundary), then its client id. A token matching no provider raises `ValueError`; it is
|
|
32
|
+
never sent to another provider.
|
|
33
|
+
- **Cache** key is `oidc-userinfo-scopes:<issuer>:<client_id>:<sub>|v1` with each segment percent-encoded
|
|
34
|
+
(so values containing `:` cannot collide, and two providers never share an entry); only the
|
|
35
|
+
userinfo-derived scopes are stored, never the userinfo response. The token's own scopes are re-derived on every call. A token without
|
|
36
|
+
`sub`, or an empty scope list, is not cached. Changes to a user's entitlements are visible after at most
|
|
37
|
+
`cache_ttl_seconds`.
|
|
38
|
+
- **Failures:** a cache write failure is logged and swallowed. A cache read failure, malformed cache
|
|
39
|
+
entry, userinfo HTTP error or missing provider is raised, so each service decides whether to fail closed
|
|
40
|
+
or degrade.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.0.22
|
|
@@ -6,6 +6,7 @@ import uuid
|
|
|
6
6
|
from typing import Any, Dict, cast, List
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
|
+
import httpx2
|
|
9
10
|
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
10
11
|
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
|
|
11
12
|
|
|
@@ -160,7 +161,12 @@ class AuthManager:
|
|
|
160
161
|
# --- Build client kwargs ---
|
|
161
162
|
client_kwargs: dict[str, Any] = {
|
|
162
163
|
"scope": auth_config.scope,
|
|
163
|
-
|
|
164
|
+
# authlib's httpx_client integration is httpx2-based as of authlib 1.8.0
|
|
165
|
+
# (authlib#909) -- this transport is passed straight into
|
|
166
|
+
# httpx2.AsyncClient.__init__, so it must wrap an httpx2 transport, not
|
|
167
|
+
# httpx's (a custom httpx-based transport= is one of authlib's own
|
|
168
|
+
# documented breaking changes for that release).
|
|
169
|
+
"transport": LoggingTransport(httpx2.AsyncHTTPTransport()),
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
if auth_config.use_pkce and auth_config.pkce_method:
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from key_value.aio.protocols.key_value import AsyncKeyValueProtocol
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from oidcauthlib.auth.config.auth_config import AuthConfig
|
|
13
|
+
from oidcauthlib.auth.config.auth_config_reader import AuthConfigReader
|
|
14
|
+
from oidcauthlib.auth.models.token import Token
|
|
15
|
+
from oidcauthlib.auth.well_known_configuration.well_known_configuration_manager import (
|
|
16
|
+
WellKnownConfigurationManager,
|
|
17
|
+
)
|
|
18
|
+
from oidcauthlib.open_telemetry.span_names import OidcOpenTelemetrySpanNames
|
|
19
|
+
from oidcauthlib.utilities.logger.log_levels import SRC_LOG_LEVELS
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
logger.setLevel(SRC_LOG_LEVELS["AUTH"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OidcUserInfoCacheEntry(BaseModel):
|
|
26
|
+
"""Document stored for one cached scope list."""
|
|
27
|
+
|
|
28
|
+
cache_version: int = Field(description="Version of the cache entry format.")
|
|
29
|
+
client_id: str | None = Field(default=None)
|
|
30
|
+
value: dict[str, Any] = Field(description="Cached payload: {'scopes': [...]}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OidcUserInfoManager:
|
|
34
|
+
"""Resolves a token's effective scopes using the OIDC userinfo endpoint, cached in a key-value store.
|
|
35
|
+
|
|
36
|
+
Some providers (e.g. Okta employee access tokens) only carry ``openid profile email groups`` in the
|
|
37
|
+
JWT; the scopes that authorize a caller appear only on the userinfo response.
|
|
38
|
+
|
|
39
|
+
Behavior:
|
|
40
|
+
- The provider is matched to the token's ``iss`` claim (exact ``issuer``, then ``iss`` contained in the
|
|
41
|
+
provider's ``well_known_uri``), then to its client id. A token matching no provider is an error:
|
|
42
|
+
the bearer token is never sent to an unrelated provider.
|
|
43
|
+
- The userinfo endpoint is discovered through ``WellKnownConfigurationManager``.
|
|
44
|
+
- Scopes are derived the way fhir-server-ui's ``jwtParser`` does: the ``scope`` claim (plus ``scp``,
|
|
45
|
+
which Okta uses), the claims named in ``scope_claims``, and each claim named in ``group_claims``
|
|
46
|
+
with its values taken whole as scopes. Commas are treated as separators, the result is
|
|
47
|
+
de-duplicated, and ``scope_remove_prefixes`` are stripped (first match wins). No claim is read by
|
|
48
|
+
default beyond ``scope``/``scp``: group and extra scope claims are configuration.
|
|
49
|
+
- The result is the union of the scopes derived from the token's own claims and from the userinfo
|
|
50
|
+
response.
|
|
51
|
+
- Only the scopes derived from the userinfo response are cached, never the response itself, under
|
|
52
|
+
``oidc-userinfo-scopes:<issuer>:<client_id>:<sub>|v<CACHE_VERSION>`` (each segment percent-encoded). The token's own scopes are always
|
|
53
|
+
re-derived from the presented token, so a narrower token never inherits a broader cached set.
|
|
54
|
+
Bumping ``CACHE_VERSION`` obsoletes every existing entry.
|
|
55
|
+
- A token without ``sub`` is never cached: the key would be shared by every user of the client.
|
|
56
|
+
- An empty scope list is never cached: it is also what an account with no scopes looks like, and
|
|
57
|
+
caching it would pin that result for the whole TTL.
|
|
58
|
+
- A cache write failure is logged and swallowed; it never fails the lookup.
|
|
59
|
+
|
|
60
|
+
The store and collection are supplied by the caller, so each service keeps its own database. A cached
|
|
61
|
+
scope list outlives a change to the user's entitlements by up to ``cache_ttl_seconds``.
|
|
62
|
+
|
|
63
|
+
Failures of the lookup itself (no matching provider, HTTP error, unreadable cache entry) are raised so
|
|
64
|
+
the caller decides whether to fail closed or degrade.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
CACHE_VERSION = 1
|
|
68
|
+
_KEY_PREFIX = "oidc-userinfo-scopes"
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
auth_config_reader: AuthConfigReader,
|
|
74
|
+
well_known_configuration_manager: WellKnownConfigurationManager,
|
|
75
|
+
store: AsyncKeyValueProtocol,
|
|
76
|
+
cache_collection: str,
|
|
77
|
+
cache_ttl_seconds: float,
|
|
78
|
+
fetch_timeout_seconds: float,
|
|
79
|
+
scope_claims: Sequence[str] = (),
|
|
80
|
+
group_claims: Sequence[str] = (),
|
|
81
|
+
scope_remove_prefixes: Sequence[str] = (),
|
|
82
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Args:
|
|
86
|
+
auth_config_reader: Reads the configured auth providers.
|
|
87
|
+
well_known_configuration_manager: Supplies the provider's discovery document.
|
|
88
|
+
store: Key-value store holding the cached scopes.
|
|
89
|
+
cache_collection: Collection of ``store`` the entries are written to.
|
|
90
|
+
cache_ttl_seconds: How long a cached entry lives.
|
|
91
|
+
fetch_timeout_seconds: Timeout of the userinfo request.
|
|
92
|
+
scope_claims: Extra claims, besides ``scope`` and ``scp``, whose values are scopes.
|
|
93
|
+
group_claims: Claims whose values are group names, taken whole as scopes.
|
|
94
|
+
scope_remove_prefixes: Prefixes stripped from each scope (first match wins).
|
|
95
|
+
transport: httpx transport, for tests.
|
|
96
|
+
"""
|
|
97
|
+
self._auth_config_reader = auth_config_reader
|
|
98
|
+
self._well_known_configuration_manager = well_known_configuration_manager
|
|
99
|
+
self._store = store
|
|
100
|
+
self._cache_collection = cache_collection
|
|
101
|
+
self._cache_ttl_seconds = cache_ttl_seconds
|
|
102
|
+
self._fetch_timeout_seconds = fetch_timeout_seconds
|
|
103
|
+
self._scope_claims = tuple(scope_claims)
|
|
104
|
+
self._group_claims = tuple(group_claims)
|
|
105
|
+
self._scope_remove_prefixes = tuple(scope_remove_prefixes)
|
|
106
|
+
self._transport = transport
|
|
107
|
+
|
|
108
|
+
def resolve_auth_config(self, *, access_token: Token) -> AuthConfig:
|
|
109
|
+
"""The configuration of the provider that issued ``access_token``.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: no configured provider matches the token's issuer or client id.
|
|
113
|
+
"""
|
|
114
|
+
issuer = access_token.issuer or (access_token.claims or {}).get("iss")
|
|
115
|
+
configs = self._auth_config_reader.get_auth_configs_for_all_auth_providers()
|
|
116
|
+
if issuer:
|
|
117
|
+
for config in configs:
|
|
118
|
+
if config.issuer and config.issuer == issuer:
|
|
119
|
+
return config
|
|
120
|
+
for config in configs:
|
|
121
|
+
if config.well_known_uri and self._is_under_issuer(uri=config.well_known_uri, issuer=issuer):
|
|
122
|
+
return config
|
|
123
|
+
client_id = access_token.client_id
|
|
124
|
+
if client_id:
|
|
125
|
+
auth_provider = self._auth_config_reader.get_provider_for_client_id(client_id=client_id)
|
|
126
|
+
if auth_provider:
|
|
127
|
+
auth_config = self._auth_config_reader.get_config_for_auth_provider(auth_provider=auth_provider)
|
|
128
|
+
if auth_config:
|
|
129
|
+
return auth_config
|
|
130
|
+
raise ValueError(f"No auth provider matches token issuer {issuer!r} or client id {client_id!r}")
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _is_under_issuer(*, uri: str, issuer: str) -> bool:
|
|
134
|
+
"""True when ``uri`` is the issuer itself or a path beneath it.
|
|
135
|
+
|
|
136
|
+
A bare substring test would also match ``https://a.example.com.au/...`` for the issuer
|
|
137
|
+
``https://a.example.com`` and send the token to the wrong provider.
|
|
138
|
+
"""
|
|
139
|
+
base = issuer.rstrip("/")
|
|
140
|
+
return uri == base or uri.startswith(f"{base}/")
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _loggable(*, cache_key: str) -> str:
|
|
144
|
+
"""Short digest of ``cache_key``: the key embeds the user's ``sub``, which must not reach logs."""
|
|
145
|
+
return hashlib.sha256(cache_key.encode()).hexdigest()[:12]
|
|
146
|
+
|
|
147
|
+
async def get_user_info_async(self, *, access_token: Token) -> dict[str, Any]:
|
|
148
|
+
"""Fetch the userinfo response for ``access_token``.
|
|
149
|
+
|
|
150
|
+
``access_token`` must already have been verified by the caller; its claims are trusted.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: the token, auth config, discovery document or response is unusable.
|
|
154
|
+
httpx.HTTPError: the userinfo request failed.
|
|
155
|
+
"""
|
|
156
|
+
if not access_token.token:
|
|
157
|
+
raise ValueError("Raw access token is required")
|
|
158
|
+
|
|
159
|
+
auth_config = self.resolve_auth_config(access_token=access_token)
|
|
160
|
+
|
|
161
|
+
well_known_result = await self._well_known_configuration_manager.get_async(auth_config=auth_config)
|
|
162
|
+
well_known_config = well_known_result.well_known_config if well_known_result else None
|
|
163
|
+
if not well_known_config:
|
|
164
|
+
raise ValueError("Well-known configuration not found for auth config")
|
|
165
|
+
|
|
166
|
+
userinfo_endpoint = well_known_config.get("userinfo_endpoint")
|
|
167
|
+
if not userinfo_endpoint or not isinstance(userinfo_endpoint, str):
|
|
168
|
+
raise ValueError("Userinfo endpoint not found in well-known configuration")
|
|
169
|
+
|
|
170
|
+
tracer = trace.get_tracer(__name__)
|
|
171
|
+
with tracer.start_as_current_span(OidcOpenTelemetrySpanNames.GET_USER_INFO_FROM_OIDC_ENDPOINT):
|
|
172
|
+
async with httpx.AsyncClient(transport=self._transport) as client:
|
|
173
|
+
response = await client.get(
|
|
174
|
+
userinfo_endpoint,
|
|
175
|
+
headers={
|
|
176
|
+
"Authorization": f"Bearer {access_token.token}",
|
|
177
|
+
"Accept": "application/json",
|
|
178
|
+
},
|
|
179
|
+
timeout=self._fetch_timeout_seconds,
|
|
180
|
+
)
|
|
181
|
+
response.raise_for_status()
|
|
182
|
+
user_info = response.json()
|
|
183
|
+
if not isinstance(user_info, dict):
|
|
184
|
+
raise ValueError("User info response is not a valid JSON object")
|
|
185
|
+
return user_info
|
|
186
|
+
|
|
187
|
+
async def get_scopes_from_user_info_async(self, *, access_token: Token) -> list[str]:
|
|
188
|
+
"""Effective scopes of ``access_token``: its own plus those of the userinfo endpoint.
|
|
189
|
+
|
|
190
|
+
The userinfo scopes come from the cache when present, else from the endpoint (then cached).
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: the cached entry has an unexpected shape, or no provider matches the token.
|
|
194
|
+
httpx.HTTPError: the userinfo request failed.
|
|
195
|
+
"""
|
|
196
|
+
tracer = trace.get_tracer(__name__)
|
|
197
|
+
with tracer.start_as_current_span(OidcOpenTelemetrySpanNames.GET_SCOPES_FROM_OIDC_USERINFO):
|
|
198
|
+
token_scopes = self.build_scopes(claims=access_token.claims or {})
|
|
199
|
+
cache_key = self.build_cache_key(
|
|
200
|
+
issuer=access_token.issuer or (access_token.claims or {}).get("iss"),
|
|
201
|
+
client_id=access_token.client_id,
|
|
202
|
+
subject=access_token.subject,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
user_info_scopes: list[str] | None = None
|
|
206
|
+
if cache_key is not None:
|
|
207
|
+
user_info_scopes = await self._read_cached_scopes(cache_key=cache_key)
|
|
208
|
+
|
|
209
|
+
if user_info_scopes is None:
|
|
210
|
+
user_info = await self.get_user_info_async(access_token=access_token)
|
|
211
|
+
user_info_scopes = self.build_scopes(claims=user_info)
|
|
212
|
+
if cache_key is not None and user_info_scopes:
|
|
213
|
+
await self._write_cached_scopes(cache_key=cache_key, scopes=user_info_scopes)
|
|
214
|
+
|
|
215
|
+
return list(dict.fromkeys([*token_scopes, *user_info_scopes]))
|
|
216
|
+
|
|
217
|
+
def build_scopes(self, *, claims: dict[str, Any]) -> list[str]:
|
|
218
|
+
"""Scopes carried by ``claims`` (a token's payload or a userinfo response)."""
|
|
219
|
+
scopes: list[str] = []
|
|
220
|
+
|
|
221
|
+
for claim_name in ("scope", "scp"):
|
|
222
|
+
scopes.extend(self._claim_values(value=claims.get(claim_name)))
|
|
223
|
+
|
|
224
|
+
for claim_name in self._scope_claims:
|
|
225
|
+
scopes.extend(self._claim_values(value=claims.get(claim_name)))
|
|
226
|
+
|
|
227
|
+
for claim_name in self._group_claims:
|
|
228
|
+
scopes.extend(self._claim_values(value=claims.get(claim_name)))
|
|
229
|
+
|
|
230
|
+
scopes = list(dict.fromkeys(scopes))
|
|
231
|
+
if self._scope_remove_prefixes:
|
|
232
|
+
scopes = [self._strip_prefix(scope=s) for s in scopes]
|
|
233
|
+
return scopes
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _claim_values(*, value: Any) -> list[str]:
|
|
237
|
+
"""String or list claim value as separate tokens; commas separate like spaces (as in jwtParser)."""
|
|
238
|
+
if isinstance(value, str):
|
|
239
|
+
return value.replace(",", " ").split()
|
|
240
|
+
if isinstance(value, list):
|
|
241
|
+
return [token for item in value if isinstance(item, str) for token in item.replace(",", " ").split()]
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
def _strip_prefix(self, *, scope: str) -> str:
|
|
245
|
+
for prefix in self._scope_remove_prefixes:
|
|
246
|
+
if scope.startswith(prefix):
|
|
247
|
+
return scope[len(prefix) :]
|
|
248
|
+
return scope
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def build_cache_key(cls, *, issuer: str | None, client_id: str | None, subject: str | None) -> str | None:
|
|
252
|
+
"""Versioned key for one user of one client of one issuer, or None when the token is not cacheable.
|
|
253
|
+
|
|
254
|
+
A token without ``sub`` or without an issuer is not cacheable: the key would be shared by every user
|
|
255
|
+
of the client, or by every provider that issues tokens without an ``iss`` claim.
|
|
256
|
+
|
|
257
|
+
Each segment is percent-encoded, so a ``:`` inside an issuer, client id or subject can never move a
|
|
258
|
+
segment boundary and make two different users share a key. A missing client id is an empty segment,
|
|
259
|
+
which no encoded value can equal.
|
|
260
|
+
"""
|
|
261
|
+
if not subject or not issuer:
|
|
262
|
+
return None
|
|
263
|
+
segments = [quote(value or "", safe="") for value in (issuer, client_id, subject)]
|
|
264
|
+
return f"{':'.join([cls._KEY_PREFIX, *segments])}|v{cls.CACHE_VERSION}"
|
|
265
|
+
|
|
266
|
+
async def _read_cached_scopes(self, *, cache_key: str) -> list[str] | None:
|
|
267
|
+
raw = await self._store.get(cache_key, collection=self._cache_collection)
|
|
268
|
+
key_id = self._loggable(cache_key=cache_key)
|
|
269
|
+
if raw is None:
|
|
270
|
+
logger.debug(f"OIDC userinfo scopes cache miss for key: {key_id}")
|
|
271
|
+
return None
|
|
272
|
+
try:
|
|
273
|
+
entry = OidcUserInfoCacheEntry.model_validate(raw)
|
|
274
|
+
except ValueError as exc:
|
|
275
|
+
raise ValueError(f"OIDC userinfo scopes cache entry has invalid shape for key: {key_id}") from exc
|
|
276
|
+
scopes = entry.value.get("scopes")
|
|
277
|
+
if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
|
|
278
|
+
raise ValueError(f"OIDC userinfo scopes cache entry has invalid shape for key: {key_id}. {type(scopes)}")
|
|
279
|
+
return scopes
|
|
280
|
+
|
|
281
|
+
async def _write_cached_scopes(self, *, cache_key: str, scopes: list[str]) -> None:
|
|
282
|
+
entry = OidcUserInfoCacheEntry(
|
|
283
|
+
cache_version=self.CACHE_VERSION,
|
|
284
|
+
client_id=None,
|
|
285
|
+
value={"scopes": scopes},
|
|
286
|
+
)
|
|
287
|
+
try:
|
|
288
|
+
await self._store.put(
|
|
289
|
+
cache_key,
|
|
290
|
+
entry.model_dump(),
|
|
291
|
+
collection=self._cache_collection,
|
|
292
|
+
ttl=self._cache_ttl_seconds,
|
|
293
|
+
)
|
|
294
|
+
logger.debug(f"Cached OIDC userinfo scopes for key: {self._loggable(cache_key=cache_key)}")
|
|
295
|
+
except Exception:
|
|
296
|
+
# A cache write failure must not break the auth flow.
|
|
297
|
+
logger.exception(f"Failed to cache OIDC userinfo scopes for key: {self._loggable(cache_key=cache_key)}")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Set
|
|
4
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
5
|
+
from opentelemetry.sdk.trace.sampling import ParentBased
|
|
6
|
+
from oidcauthlib.open_telemetry.filtering_sampler import FilteringSampler
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_excluded_span_names() -> Set[str]:
|
|
12
|
+
"""Get excluded span names from environment or use defaults."""
|
|
13
|
+
default_excluded = {"saslStart", "saslContinue", "isMaster", "ping"}
|
|
14
|
+
|
|
15
|
+
env_excluded = os.environ.get("OTEL_EXCLUDED_SPAN_NAMES", "")
|
|
16
|
+
if env_excluded:
|
|
17
|
+
custom_excluded = {name.strip() for name in env_excluded.split(",") if name.strip()}
|
|
18
|
+
logger.info("Using custom excluded span names: %s", custom_excluded)
|
|
19
|
+
return custom_excluded
|
|
20
|
+
|
|
21
|
+
return default_excluded
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_excluded_span_prefixes() -> Set[str]:
|
|
25
|
+
"""Get excluded span name prefixes from environment."""
|
|
26
|
+
env_excluded = os.environ.get("OTEL_EXCLUDED_SPAN_PREFIXES", "")
|
|
27
|
+
if env_excluded:
|
|
28
|
+
custom_excluded = {prefix.strip() for prefix in env_excluded.split(",") if prefix.strip()}
|
|
29
|
+
logger.info("Using custom excluded span prefixes: %s", custom_excluded)
|
|
30
|
+
return custom_excluded
|
|
31
|
+
|
|
32
|
+
return set()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def configure_opentelemetry(tracer_provider: TracerProvider) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Configure OpenTelemetry with span filtering.
|
|
38
|
+
|
|
39
|
+
This is called by the auto-instrumentation configurator hook.
|
|
40
|
+
"""
|
|
41
|
+
logger.info("Configuring OpenTelemetry with span filtering...")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Get current sampler
|
|
45
|
+
current_sampler = getattr(tracer_provider, "sampler", None)
|
|
46
|
+
if current_sampler is None:
|
|
47
|
+
logger.warning("Could not get current sampler from TracerProvider")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Get filtering configuration
|
|
51
|
+
excluded_span_names = get_excluded_span_names()
|
|
52
|
+
excluded_span_prefixes = get_excluded_span_prefixes()
|
|
53
|
+
|
|
54
|
+
# Create filtering sampler that wraps the current sampler
|
|
55
|
+
filtering_sampler = FilteringSampler(
|
|
56
|
+
parent_sampler=current_sampler,
|
|
57
|
+
excluded_span_names=excluded_span_names,
|
|
58
|
+
excluded_span_prefixes=excluded_span_prefixes,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Wrap it in ParentBased to respect parent sampling decisions
|
|
62
|
+
parent_based_filtering_sampler = ParentBased(root=filtering_sampler)
|
|
63
|
+
|
|
64
|
+
# Replace the sampler
|
|
65
|
+
tracer_provider.__dict__["sampler"] = parent_based_filtering_sampler
|
|
66
|
+
|
|
67
|
+
logger.info(
|
|
68
|
+
"✓ Configured OpenTelemetry with span filtering. Excluding span names: %s, prefixes: %s",
|
|
69
|
+
excluded_span_names,
|
|
70
|
+
excluded_span_prefixes,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.exception("Failed to configure OpenTelemetry span filtering", exc_info=e)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional, Set, Sequence, override
|
|
3
|
+
|
|
4
|
+
from opentelemetry.context import Context
|
|
5
|
+
from opentelemetry.sdk.trace.sampling import (
|
|
6
|
+
Sampler,
|
|
7
|
+
SamplingResult,
|
|
8
|
+
Decision,
|
|
9
|
+
ALWAYS_ON,
|
|
10
|
+
)
|
|
11
|
+
from opentelemetry.trace import Link, SpanKind, TraceState
|
|
12
|
+
from opentelemetry.util.types import Attributes
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FilteringSampler(Sampler):
|
|
18
|
+
"""
|
|
19
|
+
A sampler that filters out spans based on name patterns.
|
|
20
|
+
|
|
21
|
+
This is more reliable than a SpanProcessor because it prevents
|
|
22
|
+
spans from being created in the first place, rather than trying
|
|
23
|
+
to filter them after creation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
parent_sampler: Optional[Sampler] = None,
|
|
29
|
+
excluded_span_names: Optional[Set[str]] = None,
|
|
30
|
+
excluded_span_prefixes: Optional[Set[str]] = None,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the filtering sampler.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
parent_sampler: The sampler to delegate to for non-filtered spans
|
|
37
|
+
excluded_span_names: Set of exact span names to exclude
|
|
38
|
+
excluded_span_prefixes: Set of span name prefixes to exclude
|
|
39
|
+
"""
|
|
40
|
+
self.parent_sampler = parent_sampler or ALWAYS_ON
|
|
41
|
+
self.excluded_span_names = excluded_span_names or {
|
|
42
|
+
"saslStart",
|
|
43
|
+
"saslContinue",
|
|
44
|
+
"isMaster",
|
|
45
|
+
"ping",
|
|
46
|
+
}
|
|
47
|
+
self.excluded_span_prefixes = excluded_span_prefixes or set()
|
|
48
|
+
|
|
49
|
+
logger.info(
|
|
50
|
+
"FilteringSampler initialized. Excluding span names: %s, prefixes: %s",
|
|
51
|
+
self.excluded_span_names,
|
|
52
|
+
self.excluded_span_prefixes,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@override
|
|
56
|
+
def should_sample(
|
|
57
|
+
self,
|
|
58
|
+
parent_context: Optional["Context"],
|
|
59
|
+
trace_id: int,
|
|
60
|
+
name: str,
|
|
61
|
+
kind: Optional[SpanKind] = None,
|
|
62
|
+
attributes: Optional[Attributes] = None,
|
|
63
|
+
links: Optional[Sequence[Link]] = None,
|
|
64
|
+
trace_state: Optional["TraceState"] = None,
|
|
65
|
+
) -> SamplingResult:
|
|
66
|
+
"""
|
|
67
|
+
Determine if a span should be sampled.
|
|
68
|
+
|
|
69
|
+
Returns DROP for filtered spans, otherwise delegates to parent sampler.
|
|
70
|
+
"""
|
|
71
|
+
# Check exact match
|
|
72
|
+
if name in self.excluded_span_names:
|
|
73
|
+
logger.debug("Filtering span (exact match): %s", name)
|
|
74
|
+
return SamplingResult(Decision.DROP)
|
|
75
|
+
|
|
76
|
+
# Check prefix match
|
|
77
|
+
for prefix in self.excluded_span_prefixes:
|
|
78
|
+
if name.startswith(prefix):
|
|
79
|
+
logger.debug("Filtering span (prefix match): %s", name)
|
|
80
|
+
return SamplingResult(Decision.DROP)
|
|
81
|
+
|
|
82
|
+
logger.debug(f"FilteringSampler: Not filtering span: {name}")
|
|
83
|
+
|
|
84
|
+
# Delegate to parent sampler
|
|
85
|
+
return self.parent_sampler.should_sample(parent_context, trace_id, name, kind, attributes, links, trace_state)
|
|
86
|
+
|
|
87
|
+
@override
|
|
88
|
+
def get_description(self) -> str:
|
|
89
|
+
"""Get a description of this sampler."""
|
|
90
|
+
return f"FilteringSampler(parent={self.parent_sampler.get_description()})"
|