bipixie-mcp 0.3.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.
- bipixie_mcp/__init__.py +57 -0
- bipixie_mcp/auth.py +446 -0
- bipixie_mcp/config.py +527 -0
- bipixie_mcp/discovery.py +235 -0
- bipixie_mcp/powerbi_client.py +1363 -0
- bipixie_mcp/server.py +279 -0
- bipixie_mcp/targeting.py +189 -0
- bipixie_mcp/tools.py +3137 -0
- bipixie_mcp-0.3.0.dist-info/METADATA +513 -0
- bipixie_mcp-0.3.0.dist-info/RECORD +14 -0
- bipixie_mcp-0.3.0.dist-info/WHEEL +5 -0
- bipixie_mcp-0.3.0.dist-info/entry_points.txt +2 -0
- bipixie_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
- bipixie_mcp-0.3.0.dist-info/top_level.txt +1 -0
bipixie_mcp/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bipixie_mcp — BI Pixie MCP Server package.
|
|
3
|
+
|
|
4
|
+
Exposes the stable public surface for tests and embedders. Import-side-effect free:
|
|
5
|
+
no Settings are instantiated, no credential is built, and no network I/O is performed
|
|
6
|
+
at import time. This keeps stdout clean for the JSON-RPC stream (stdio transport) and
|
|
7
|
+
allows tests to patch environment variables before any config object is constructed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__: str = "0.3.0"
|
|
13
|
+
|
|
14
|
+
# Re-export the stable public surface. Names are imported lazily via __getattr__
|
|
15
|
+
# below so that the actual modules (and their transitive imports such as fastmcp,
|
|
16
|
+
# azure-identity, msal, httpx) are NOT loaded until the caller explicitly requests
|
|
17
|
+
# them. This is critical for the stdio transport: importing this package in a test
|
|
18
|
+
# that patches os.environ must not trigger Settings() construction or MSAL cache
|
|
19
|
+
# initialisation.
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"Settings",
|
|
24
|
+
"get_settings",
|
|
25
|
+
"build_server",
|
|
26
|
+
"main",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Populated on first access through __getattr__; never pre-loaded.
|
|
30
|
+
_LAZY_MAP: dict[str, str] = {
|
|
31
|
+
"Settings": "bipixie_mcp.config",
|
|
32
|
+
"get_settings": "bipixie_mcp.config",
|
|
33
|
+
"build_server": "bipixie_mcp.server",
|
|
34
|
+
"main": "bipixie_mcp.server",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name: str) -> object:
|
|
39
|
+
"""
|
|
40
|
+
Lazy loader for the public API.
|
|
41
|
+
|
|
42
|
+
Importing a public name (e.g. ``from bipixie_mcp import Settings``) triggers
|
|
43
|
+
this function, which imports the relevant sub-module on first access and caches
|
|
44
|
+
the result in the package namespace so subsequent accesses are O(1).
|
|
45
|
+
"""
|
|
46
|
+
if name not in _LAZY_MAP:
|
|
47
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
48
|
+
|
|
49
|
+
module_name = _LAZY_MAP[name]
|
|
50
|
+
import importlib
|
|
51
|
+
|
|
52
|
+
module = importlib.import_module(module_name)
|
|
53
|
+
obj = getattr(module, name)
|
|
54
|
+
|
|
55
|
+
# Cache in the package namespace to avoid repeated __getattr__ calls.
|
|
56
|
+
globals()[name] = obj
|
|
57
|
+
return obj
|
bipixie_mcp/auth.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Token acquisition for the BI Pixie MCP server.
|
|
2
|
+
|
|
3
|
+
This module is the single auth boundary for every outbound call the server makes
|
|
4
|
+
to the Power BI REST API. It maps the configured ``BIPIXIE_MCP_AUTH_MODE`` onto a
|
|
5
|
+
concrete ``azure.core.credentials.TokenCredential`` and hands back a cached bearer
|
|
6
|
+
token scoped to ``settings.powerbi_scope``
|
|
7
|
+
(``https://analysis.windows.net/powerbi/api/.default``).
|
|
8
|
+
|
|
9
|
+
Auth modes are selected by how the server is HOSTED, not by row visibility: the BI
|
|
10
|
+
Pixie semantic model ships no row-level security, so every mode sees the same full
|
|
11
|
+
usage data. Each Enterprise team installs the BI Pixie Dashboard under its own
|
|
12
|
+
license key / container and points its own MCP server at its own model, so a caller
|
|
13
|
+
who can run the server is already entitled to that model's usage data. The same
|
|
14
|
+
package serves both multi-tenant cloud and self-hosted enterprise deployments:
|
|
15
|
+
|
|
16
|
+
* LOCAL (stdio / Claude Code / Codex on a developer machine)
|
|
17
|
+
- ``device_code`` (default): :class:`azure.identity.DeviceCodeCredential`
|
|
18
|
+
- ``interactive``: :class:`azure.identity.InteractiveBrowserCredential`
|
|
19
|
+
Both are *public-client* flows (no client secret) and persist an MSAL token
|
|
20
|
+
cache at ``settings.token_cache_path`` so the user is not re-prompted on every
|
|
21
|
+
restart.
|
|
22
|
+
- ``azure_cli``: :class:`azure.identity.AzureCliCredential` — reuses an existing
|
|
23
|
+
``az login`` with no app registration; the convenient local-dev / validation
|
|
24
|
+
mode.
|
|
25
|
+
|
|
26
|
+
* HOSTED (streamable-http inside the customer's Azure)
|
|
27
|
+
- ``service_principal``: :class:`azure.identity.ClientSecretCredential`
|
|
28
|
+
(mirrors ``control_plane_app/shared/template_app_client.py``)
|
|
29
|
+
- ``managed_identity``: :class:`azure.identity.DefaultAzureCredential`
|
|
30
|
+
(zero-secret; the MI is granted Member/Build on the workspace)
|
|
31
|
+
|
|
32
|
+
``ResponsePiiFilter`` + ``BIPIXIE_MCP_PII_COLUMNS_ALLOWED`` remain the privacy
|
|
33
|
+
control for user-identifying columns, independent of auth mode.
|
|
34
|
+
|
|
35
|
+
Security notes
|
|
36
|
+
--------------
|
|
37
|
+
* This module performs READ-ONLY data access only -- it never requests a
|
|
38
|
+
``ReadWrite`` scope; the resolved ``.default`` scope should map to the app reg's
|
|
39
|
+
``Dataset.Read.All``.
|
|
40
|
+
* Secret values and raw bearer tokens are NEVER logged. A redacting
|
|
41
|
+
:class:`logging.Filter` (:class:`SecretRedactingFilter`) is attached to this
|
|
42
|
+
module's logger and masks anything that looks like a GUID, a long hex/base64url
|
|
43
|
+
blob, or a JWT. Per project convention all log output is emitted to stderr;
|
|
44
|
+
configuration of handlers/levels happens in ``server.py``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import threading
|
|
53
|
+
import time
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import TYPE_CHECKING, Final
|
|
56
|
+
|
|
57
|
+
from azure.core.credentials import AccessToken, TokenCredential
|
|
58
|
+
from azure.core.exceptions import ClientAuthenticationError
|
|
59
|
+
from azure.identity import (
|
|
60
|
+
AzureCliCredential,
|
|
61
|
+
ClientSecretCredential,
|
|
62
|
+
DefaultAzureCredential,
|
|
63
|
+
DeviceCodeCredential,
|
|
64
|
+
InteractiveBrowserCredential,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING: # avoid an import cycle / hard dependency at module-load time
|
|
68
|
+
from .config import Settings
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"AuthError",
|
|
72
|
+
"TokenProvider",
|
|
73
|
+
"build_credential",
|
|
74
|
+
"get_powerbi_token",
|
|
75
|
+
"SecretRedactingFilter",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# Logging (stderr only; handlers configured in server.py). Secrets redacted.
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SecretRedactingFilter(logging.Filter):
|
|
85
|
+
"""Mask secret-shaped substrings in any record routed through this logger.
|
|
86
|
+
|
|
87
|
+
Defense-in-depth: even if a future code path accidentally interpolates a token,
|
|
88
|
+
secret, or GUID into a log message, this filter scrubs it before the record
|
|
89
|
+
reaches a handler. We intentionally over-redact (GUIDs, long hex/base64url runs,
|
|
90
|
+
JWTs) rather than risk leaking an Entra credential or bearer token.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# GUID (tenant/client IDs), 40+ char hex (token fragments / client secrets),
|
|
94
|
+
# 20+ char base64url runs, and three-segment JWTs.
|
|
95
|
+
_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
|
|
96
|
+
re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
97
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"),
|
|
98
|
+
re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"),
|
|
99
|
+
re.compile(r"\b[0-9a-fA-F]{40,}\b"),
|
|
100
|
+
re.compile(r"\b[A-Za-z0-9_\-]{20,}\b"),
|
|
101
|
+
)
|
|
102
|
+
_MASK: Final[str] = "***REDACTED***"
|
|
103
|
+
|
|
104
|
+
def _scrub(self, text: str) -> str:
|
|
105
|
+
for pattern in self._PATTERNS:
|
|
106
|
+
text = pattern.sub(self._MASK, text)
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 - stdlib name
|
|
110
|
+
try:
|
|
111
|
+
message = record.getMessage()
|
|
112
|
+
except Exception: # pragma: no cover - never let logging break auth
|
|
113
|
+
return True
|
|
114
|
+
scrubbed = self._scrub(message)
|
|
115
|
+
if scrubbed != message:
|
|
116
|
+
record.msg = scrubbed
|
|
117
|
+
record.args = ()
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
logger = logging.getLogger(__name__)
|
|
122
|
+
logger.addFilter(SecretRedactingFilter())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# Errors
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AuthError(Exception):
|
|
131
|
+
"""Raised when a bearer token cannot be acquired for the Power BI API.
|
|
132
|
+
|
|
133
|
+
Messages are deliberately non-secret and setup-oriented (e.g. a hint that the
|
|
134
|
+
'Allow service principals to use Power BI APIs' tenant setting is required, or
|
|
135
|
+
that the workspace role is missing) so they are safe to surface to an operator
|
|
136
|
+
or an MCP client.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# How long before the real expiry we proactively refresh, so a token never expires
|
|
141
|
+
# mid-flight on a slow Power BI query. Power BI tokens live ~1h; 5 min is generous.
|
|
142
|
+
_REFRESH_SKEW_SECONDS: Final[int] = 300
|
|
143
|
+
|
|
144
|
+
# Per-credential-construction hint appended to AuthError messages for 403/consent
|
|
145
|
+
# style failures. Kept terse and secret-free.
|
|
146
|
+
_SP_DISABLED_HINT: Final[str] = (
|
|
147
|
+
"If using service_principal/managed_identity auth, confirm the Power BI tenant "
|
|
148
|
+
"setting 'Allow service principals to use Power BI APIs' is enabled and that the "
|
|
149
|
+
"identity has at least the Member role on the target workspace."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
# Credential construction
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load_token_cache_seed(token_cache_path: str) -> str | None:
|
|
159
|
+
"""Return the serialized MSAL cache blob for a public-client credential.
|
|
160
|
+
|
|
161
|
+
azure-identity's public-client credentials accept a ``cache_persistence_options``
|
|
162
|
+
arg in newer SDKs, but the broadly-portable contract here is to seed the
|
|
163
|
+
in-memory MSAL cache from a previously serialized blob and let the caller re-serialize
|
|
164
|
+
on shutdown. We read the blob if the file exists; absence is not an error
|
|
165
|
+
(first run). Read failures are downgraded to a warning -- a missing cache only
|
|
166
|
+
costs the user one extra interactive prompt, never correctness.
|
|
167
|
+
"""
|
|
168
|
+
path = Path(os.path.expanduser(token_cache_path))
|
|
169
|
+
if not path.is_file():
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
return path.read_text(encoding="utf-8")
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
# Logs only the cache file PATH + exception class name, never token bytes;
|
|
175
|
+
# SecretRedactingFilter scrubs defensively. Semgrep heuristic false positive:
|
|
176
|
+
logger.warning( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
|
|
177
|
+
"Could not read MSAL token cache at %s (%s); an interactive prompt "
|
|
178
|
+
"may be required.",
|
|
179
|
+
path,
|
|
180
|
+
exc.__class__.__name__,
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_public_client_credential(
|
|
186
|
+
settings: "Settings",
|
|
187
|
+
) -> TokenCredential:
|
|
188
|
+
"""Construct a DeviceCode/InteractiveBrowser credential for LOCAL stdio use.
|
|
189
|
+
|
|
190
|
+
Both are public-client flows -- no client secret. A persistent MSAL token cache
|
|
191
|
+
at ``settings.token_cache_path`` is enabled where the installed azure-identity
|
|
192
|
+
version supports ``TokenCachePersistenceOptions`` so the refresh token survives
|
|
193
|
+
process restarts; otherwise we fall back to an in-memory cache (one prompt per
|
|
194
|
+
process) without failing.
|
|
195
|
+
"""
|
|
196
|
+
if not settings.client_id:
|
|
197
|
+
raise AuthError(
|
|
198
|
+
f"auth_mode='{settings.auth_mode}' requires BIPIXIE_MCP_CLIENT_ID "
|
|
199
|
+
"(a public-client app registration in the configured tenant)."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
common_kwargs: dict[str, object] = {
|
|
203
|
+
"tenant_id": settings.tenant_id,
|
|
204
|
+
"client_id": settings.client_id,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Best-effort persistent cache. Import lazily because the class name/availability
|
|
208
|
+
# varies across azure-identity releases; a failure here must not break auth.
|
|
209
|
+
cache_options = None
|
|
210
|
+
try: # pragma: no cover - environment-dependent
|
|
211
|
+
from azure.identity import TokenCachePersistenceOptions
|
|
212
|
+
|
|
213
|
+
cache_dir = Path(os.path.expanduser(settings.token_cache_path)).parent
|
|
214
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
cache_options = TokenCachePersistenceOptions(
|
|
216
|
+
name="bipixie-mcp",
|
|
217
|
+
allow_unencrypted_storage=True,
|
|
218
|
+
)
|
|
219
|
+
except Exception as exc: # noqa: BLE001 - persistence is optional
|
|
220
|
+
# Logs only the exception class name, never secrets. Semgrep false positive:
|
|
221
|
+
logger.debug( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
|
|
222
|
+
"Persistent token cache unavailable (%s); using in-memory cache.",
|
|
223
|
+
exc.__class__.__name__,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if cache_options is not None:
|
|
227
|
+
common_kwargs["cache_persistence_options"] = cache_options
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
if settings.auth_mode == "interactive":
|
|
231
|
+
return InteractiveBrowserCredential(**common_kwargs) # type: ignore[arg-type]
|
|
232
|
+
# default LOCAL mode
|
|
233
|
+
return DeviceCodeCredential(**common_kwargs) # type: ignore[arg-type]
|
|
234
|
+
except TypeError:
|
|
235
|
+
# Older azure-identity that does not accept cache_persistence_options.
|
|
236
|
+
common_kwargs.pop("cache_persistence_options", None)
|
|
237
|
+
if settings.auth_mode == "interactive":
|
|
238
|
+
return InteractiveBrowserCredential(**common_kwargs) # type: ignore[arg-type]
|
|
239
|
+
return DeviceCodeCredential(**common_kwargs) # type: ignore[arg-type]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_credential(settings: "Settings") -> TokenCredential:
|
|
243
|
+
"""Map ``settings.auth_mode`` onto a concrete azure-identity credential.
|
|
244
|
+
|
|
245
|
+
This is the single place that knows how each mode is wired:
|
|
246
|
+
|
|
247
|
+
* ``device_code`` -> :class:`DeviceCodeCredential` (public client)
|
|
248
|
+
* ``interactive`` -> :class:`InteractiveBrowserCredential` (public client)
|
|
249
|
+
* ``azure_cli`` -> :class:`AzureCliCredential` (reuses ``az login``; local dev)
|
|
250
|
+
* ``service_principal``-> :class:`ClientSecretCredential` (app token)
|
|
251
|
+
* ``managed_identity`` -> :class:`DefaultAzureCredential` (app token)
|
|
252
|
+
|
|
253
|
+
Construction is side-effect-light: no token is acquired here. :class:`TokenProvider`
|
|
254
|
+
drives the actual ``get_token`` call lazily. Raises :class:`AuthError` for an
|
|
255
|
+
unknown mode or missing required configuration (mirroring, but not replacing,
|
|
256
|
+
``Settings.validate_runtime``).
|
|
257
|
+
"""
|
|
258
|
+
mode = settings.auth_mode
|
|
259
|
+
|
|
260
|
+
if mode in ("device_code", "interactive"):
|
|
261
|
+
return _build_public_client_credential(settings)
|
|
262
|
+
|
|
263
|
+
if mode == "service_principal":
|
|
264
|
+
if not (settings.client_id and settings.client_secret):
|
|
265
|
+
raise AuthError(
|
|
266
|
+
"auth_mode='service_principal' requires both "
|
|
267
|
+
"BIPIXIE_MCP_CLIENT_ID and BIPIXIE_MCP_CLIENT_SECRET."
|
|
268
|
+
)
|
|
269
|
+
return ClientSecretCredential(
|
|
270
|
+
tenant_id=settings.tenant_id,
|
|
271
|
+
client_id=settings.client_id,
|
|
272
|
+
client_secret=settings.client_secret,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if mode == "azure_cli":
|
|
276
|
+
# Reuse the developer's existing `az login` — no app registration, no
|
|
277
|
+
# secret. AzureCliCredential shells out to `az account get-access-token`.
|
|
278
|
+
# Ideal for local validation/dev. Pass tenant_id when set (newer
|
|
279
|
+
# azure-identity); fall back gracefully on older versions that lack it.
|
|
280
|
+
try:
|
|
281
|
+
return (
|
|
282
|
+
AzureCliCredential(tenant_id=settings.tenant_id)
|
|
283
|
+
if settings.tenant_id
|
|
284
|
+
else AzureCliCredential()
|
|
285
|
+
)
|
|
286
|
+
except TypeError:
|
|
287
|
+
return AzureCliCredential()
|
|
288
|
+
|
|
289
|
+
if mode == "managed_identity":
|
|
290
|
+
# DefaultAzureCredential resolves the Azure-hosted managed identity (and
|
|
291
|
+
# honors AZURE_CLIENT_ID for a user-assigned MI). No secret needed.
|
|
292
|
+
return DefaultAzureCredential()
|
|
293
|
+
|
|
294
|
+
raise AuthError(
|
|
295
|
+
f"Unknown BIPIXIE_MCP_AUTH_MODE '{mode}'. Expected one of: "
|
|
296
|
+
"device_code, interactive, azure_cli, service_principal, managed_identity."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
# Token provider (cached + auto-refresh)
|
|
302
|
+
# --------------------------------------------------------------------------- #
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class TokenProvider:
|
|
306
|
+
"""Acquire and cache a Power BI bearer token for the configured identity.
|
|
307
|
+
|
|
308
|
+
A single :class:`TokenProvider` is created once at server startup (in
|
|
309
|
+
``server.build_server``) and shared by :class:`powerbi_client.PowerBIClient`.
|
|
310
|
+
The underlying azure-identity credential is constructed lazily on first use and
|
|
311
|
+
then reused process-wide; tokens are cached and proactively refreshed about
|
|
312
|
+
five minutes before expiry, so the credential is *not* invoked per request.
|
|
313
|
+
|
|
314
|
+
Thread-safety: ``get_token`` is guarded by a lock because the streamable-http
|
|
315
|
+
transport may dispatch tool calls concurrently. The hot path (valid cached
|
|
316
|
+
token) takes the lock only briefly to read the cached value.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def __init__(self, settings: "Settings") -> None:
|
|
320
|
+
self._settings = settings
|
|
321
|
+
self._scope: str = settings.powerbi_scope
|
|
322
|
+
self._lock = threading.Lock()
|
|
323
|
+
self._credential: TokenCredential | None = None
|
|
324
|
+
self._cached_token: AccessToken | None = None
|
|
325
|
+
|
|
326
|
+
# -- public API ------------------------------------------------------- #
|
|
327
|
+
|
|
328
|
+
def get_token(self) -> str:
|
|
329
|
+
"""Return a valid bearer token for ``settings.powerbi_scope``.
|
|
330
|
+
|
|
331
|
+
Reuses the cached token until it is within ``_REFRESH_SKEW_SECONDS`` of
|
|
332
|
+
expiry, then transparently refreshes. Raises :class:`AuthError` (never a
|
|
333
|
+
raw azure exception, and never a message containing a secret) on failure.
|
|
334
|
+
"""
|
|
335
|
+
with self._lock:
|
|
336
|
+
cached = self._cached_token
|
|
337
|
+
if cached is not None and not self._is_expiring(cached):
|
|
338
|
+
return cached.token
|
|
339
|
+
|
|
340
|
+
credential = self._ensure_credential()
|
|
341
|
+
token = self._acquire(credential)
|
|
342
|
+
self._cached_token = token
|
|
343
|
+
return token.token
|
|
344
|
+
|
|
345
|
+
# -- internals -------------------------------------------------------- #
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _is_expiring(token: AccessToken) -> bool:
|
|
349
|
+
"""True if the token is unset, already expired, or inside the refresh skew."""
|
|
350
|
+
return token.expires_on - _REFRESH_SKEW_SECONDS <= int(time.time())
|
|
351
|
+
|
|
352
|
+
def _ensure_credential(self) -> TokenCredential:
|
|
353
|
+
if self._credential is None:
|
|
354
|
+
self._credential = build_credential(self._settings)
|
|
355
|
+
# Logs only the auth-mode enum (device_code/azure_cli/...), not a secret.
|
|
356
|
+
logger.info( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
|
|
357
|
+
"Initialized Power BI credential (auth_mode=%s).",
|
|
358
|
+
self._settings.auth_mode,
|
|
359
|
+
)
|
|
360
|
+
return self._credential
|
|
361
|
+
|
|
362
|
+
def _acquire(self, credential: TokenCredential) -> AccessToken:
|
|
363
|
+
try:
|
|
364
|
+
token = credential.get_token(self._scope)
|
|
365
|
+
except ClientAuthenticationError as exc:
|
|
366
|
+
raise AuthError(self._explain(exc)) from exc
|
|
367
|
+
except Exception as exc: # noqa: BLE001 - normalize to AuthError
|
|
368
|
+
raise AuthError(
|
|
369
|
+
f"Failed to acquire a Power BI token ({exc.__class__.__name__}). "
|
|
370
|
+
f"{_SP_DISABLED_HINT}"
|
|
371
|
+
) from exc
|
|
372
|
+
|
|
373
|
+
if not token or not getattr(token, "token", None):
|
|
374
|
+
raise AuthError(
|
|
375
|
+
"Token acquisition returned an empty bearer for scope "
|
|
376
|
+
f"'{self._scope}'. {_SP_DISABLED_HINT}"
|
|
377
|
+
)
|
|
378
|
+
# Logs only the token EXPIRY timestamp, never the token itself. False positive:
|
|
379
|
+
logger.debug("Acquired Power BI token (expires_on=%s).", token.expires_on) # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
|
|
380
|
+
return token
|
|
381
|
+
|
|
382
|
+
def _explain(self, exc: ClientAuthenticationError) -> str:
|
|
383
|
+
"""Build a non-secret, setup-oriented message from an auth failure.
|
|
384
|
+
|
|
385
|
+
We surface the credential type and a remediation hint rather than the raw
|
|
386
|
+
exception text (which can embed correlation IDs / claims). Service-principal
|
|
387
|
+
and managed-identity failures get the tenant-setting/workspace-role hint.
|
|
388
|
+
"""
|
|
389
|
+
mode = self._settings.auth_mode
|
|
390
|
+
base = (
|
|
391
|
+
f"Could not authenticate to Power BI using auth_mode='{mode}' "
|
|
392
|
+
f"for scope '{self._scope}'."
|
|
393
|
+
)
|
|
394
|
+
if mode in ("service_principal", "managed_identity"):
|
|
395
|
+
return f"{base} {_SP_DISABLED_HINT}"
|
|
396
|
+
if mode in ("device_code", "interactive"):
|
|
397
|
+
return (
|
|
398
|
+
f"{base} Confirm BIPIXIE_MCP_CLIENT_ID is a public-client app "
|
|
399
|
+
"registration in the configured tenant and that you completed the "
|
|
400
|
+
"sign-in prompt."
|
|
401
|
+
)
|
|
402
|
+
if mode == "azure_cli":
|
|
403
|
+
return (
|
|
404
|
+
f"{base} Confirm you have run 'az login' and that the active Azure "
|
|
405
|
+
"CLI account can reach the Power BI API in this tenant."
|
|
406
|
+
)
|
|
407
|
+
return base
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# --------------------------------------------------------------------------- #
|
|
411
|
+
# Module-level async convenience
|
|
412
|
+
# --------------------------------------------------------------------------- #
|
|
413
|
+
|
|
414
|
+
# Process-wide TokenProvider cache keyed by the identity-relevant config tuple, so
|
|
415
|
+
# repeated get_powerbi_token() calls reuse one credential + token cache. Keyed on the
|
|
416
|
+
# fields that determine the identity/scope so a re-configured Settings yields a fresh
|
|
417
|
+
# provider rather than a stale token.
|
|
418
|
+
_PROVIDER_LOCK = threading.Lock()
|
|
419
|
+
_PROVIDERS: dict[tuple, TokenProvider] = {}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _provider_key(settings: "Settings") -> tuple:
|
|
423
|
+
return (
|
|
424
|
+
settings.tenant_id,
|
|
425
|
+
settings.auth_mode,
|
|
426
|
+
settings.client_id,
|
|
427
|
+
settings.powerbi_scope,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def get_powerbi_token(settings: "Settings") -> str:
|
|
432
|
+
"""Async-friendly accessor returning a cached Power BI bearer token.
|
|
433
|
+
|
|
434
|
+
Thin coroutine wrapper over :class:`TokenProvider` for callers (and tools) that
|
|
435
|
+
operate in an async context. The MSAL/azure-identity token acquisition itself is
|
|
436
|
+
synchronous and almost always served from cache; the rare network refresh is
|
|
437
|
+
brief, so it runs inline. Providers are cached per identity-config so concurrent
|
|
438
|
+
callers share one credential and one token.
|
|
439
|
+
"""
|
|
440
|
+
key = _provider_key(settings)
|
|
441
|
+
with _PROVIDER_LOCK:
|
|
442
|
+
provider = _PROVIDERS.get(key)
|
|
443
|
+
if provider is None:
|
|
444
|
+
provider = TokenProvider(settings)
|
|
445
|
+
_PROVIDERS[key] = provider
|
|
446
|
+
return provider.get_token()
|