coding-client 1.21.2__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.
Files changed (109) hide show
  1. coding_client/__init__.py +1 -0
  2. coding_client/api/__init__.py +19 -0
  3. coding_client/api/exception_handlers.py +158 -0
  4. coding_client/api/logging_setup.py +44 -0
  5. coding_client/api/metrics.py +40 -0
  6. coding_client/api/openapi_config.py +124 -0
  7. coding_client/api/request_logging.py +60 -0
  8. coding_client/api/validation.py +150 -0
  9. coding_client/auth/__init__.py +78 -0
  10. coding_client/auth/_default_tenant.py +132 -0
  11. coding_client/auth/_header_trust.py +98 -0
  12. coding_client/auth/_onbehalf.py +315 -0
  13. coding_client/auth/_trusted_networks.py +94 -0
  14. coding_client/auth/dependencies.py +61 -0
  15. coding_client/auth/middleware.py +445 -0
  16. coding_client/auth/models.py +83 -0
  17. coding_client/auth/outbound.py +137 -0
  18. coding_client/auth/rbac.py +21 -0
  19. coding_client/auth/rbac_coding.py +5 -0
  20. coding_client/auth/tenant.py +650 -0
  21. coding_client/auth/websocket.py +148 -0
  22. coding_client/clients/__init__.py +24 -0
  23. coding_client/clients/base.py +131 -0
  24. coding_client/clients/coding_ai.py +33 -0
  25. coding_client/clients/coding_analysis.py +36 -0
  26. coding_client/clients/coding_containers.py +45 -0
  27. coding_client/clients/coding_github.py +127 -0
  28. coding_client/clients/coding_realtime.py +127 -0
  29. coding_client/clients/coding_security.py +51 -0
  30. coding_client/clients/coding_testing.py +31 -0
  31. coding_client/clients/coding_workspaces.py +265 -0
  32. coding_client/config/__init__.py +12 -0
  33. coding_client/config/base_settings.py +61 -0
  34. coding_client/config/yaml_config_loader.py +172 -0
  35. coding_client/db/__init__.py +42 -0
  36. coding_client/db/db_types.py +122 -0
  37. coding_client/db/exceptions.py +59 -0
  38. coding_client/db/postgres_connection.py +572 -0
  39. coding_client/db/tenant_query_helper.py +417 -0
  40. coding_client/db_init.py +49 -0
  41. coding_client/exceptions/__init__.py +67 -0
  42. coding_client/exceptions/api_exceptions.py +395 -0
  43. coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
  44. coding_client/interfaces/__init__.py +22 -0
  45. coding_client/interfaces/cache_interface.py +179 -0
  46. coding_client/interfaces/container_interface.py +49 -0
  47. coding_client/interfaces/docker_interface.py +283 -0
  48. coding_client/interfaces/graph_interface.py +28 -0
  49. coding_client/interfaces/security_interface.py +72 -0
  50. coding_client/interfaces/webhook_interface.py +67 -0
  51. coding_client/llm/__init__.py +54 -0
  52. coding_client/llm/base_agent.py +195 -0
  53. coding_client/llm/context.py +121 -0
  54. coding_client/llm/responses.py +102 -0
  55. coding_client/llm/routing.py +82 -0
  56. coding_client/llm/settings.py +102 -0
  57. coding_client/models/__init__.py +62 -0
  58. coding_client/models/agent.py +38 -0
  59. coding_client/models/ai_test_failure.py +745 -0
  60. coding_client/models/analysis.py +330 -0
  61. coding_client/models/architecture.py +685 -0
  62. coding_client/models/bug.py +128 -0
  63. coding_client/models/code_graph.py +317 -0
  64. coding_client/models/container.py +241 -0
  65. coding_client/models/core.py +878 -0
  66. coding_client/models/docker_container.py +104 -0
  67. coding_client/models/github.py +293 -0
  68. coding_client/models/logging.py +29 -0
  69. coding_client/models/package_manager.py +257 -0
  70. coding_client/models/pr_validation.py +440 -0
  71. coding_client/models/pydantic_ai.py +395 -0
  72. coding_client/models/repo_scanner.py +249 -0
  73. coding_client/models/responses.py +431 -0
  74. coding_client/models/security.py +185 -0
  75. coding_client/models/static_analysis.py +508 -0
  76. coding_client/models/system.py +48 -0
  77. coding_client/models/test_executor.py +204 -0
  78. coding_client/models/testing.py +673 -0
  79. coding_client/models/testing_config.py +200 -0
  80. coding_client/models/validation.py +127 -0
  81. coding_client/models_init.py +272 -0
  82. coding_client/telemetry.py +195 -0
  83. coding_client/testing.py +180 -0
  84. coding_client/types/__init__.py +130 -0
  85. coding_client/types/constraint_types.py +276 -0
  86. coding_client/types/core_types.py +112 -0
  87. coding_client/types/state_machine.py +443 -0
  88. coding_client/utils/__init__.py +86 -0
  89. coding_client/utils/archive_utils.py +387 -0
  90. coding_client/utils/ast_debug.py +45 -0
  91. coding_client/utils/async_timeout.py +364 -0
  92. coding_client/utils/constants.py +518 -0
  93. coding_client/utils/content_processing.py +326 -0
  94. coding_client/utils/database.py +916 -0
  95. coding_client/utils/encoding_helper.py +222 -0
  96. coding_client/utils/enum_helpers.py +166 -0
  97. coding_client/utils/file_operations.py +177 -0
  98. coding_client/utils/filesystem_ops.py +528 -0
  99. coding_client/utils/http_client.py +562 -0
  100. coding_client/utils/path_utils.py +471 -0
  101. coding_client/utils/profiling.py +421 -0
  102. coding_client/utils/response_builder.py +240 -0
  103. coding_client/utils/retry_helper.py +712 -0
  104. coding_client/utils/security_validation.py +423 -0
  105. coding_client-1.21.2.dist-info/METADATA +234 -0
  106. coding_client-1.21.2.dist-info/RECORD +109 -0
  107. coding_client-1.21.2.dist-info/WHEEL +5 -0
  108. coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
  109. coding_client-1.21.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,650 @@
1
+ """Tenant isolation middleware and dependencies.
2
+
3
+ This module provides:
4
+ 1. TenantMiddleware - Extracts X-Tenant-Id header and sets it on the user
5
+ 2. get_tenant_id dependency - For use in route handlers to get current tenant
6
+ 3. require_tenant dependency - Ensures tenant_id is present
7
+ 4. create_tenant_context - Creates TenantContext from request for service layer
8
+
9
+ Priority order for tenant extraction:
10
+ 1. X-Tenant-Id header (from ext_authz if it arrives through ztunnel)
11
+ 2. Authorization: Bearer <token> -> license introspection endpoint
12
+ 3. User state fallback (for internal service-to-service calls)
13
+ 4. Trusted-network bypass -> DEFAULT_TENANT_ID (TRUSTED_CIDRS, empty by default)
14
+
15
+ ⚠️ Step 1 outranks step 2, and step 1 is an UNVERIFIED claim. A caller that can
16
+ reach the port pod-to-pod (which does not traverse the edge ext_authz filter
17
+ that would have stripped and re-injected the header) can assert any tenant and
18
+ be served it, without any license at all. ``TRUST_TENANT_HEADER=false`` turns
19
+ step 1 off for a service; see :mod:`._header_trust` for why the default leaves
20
+ it on. That gate applies to every place in this module that reads an identity
21
+ header — ``TenantMiddleware``, ``require_tenant``, ``optional_tenant``,
22
+ ``get_current_user_with_tenant`` and ``create_tenant_context`` — and to
23
+ ``AuthMiddleware`` in ``middleware.py``. They must be flipped together or a
24
+ service closes one route and leaves the neighbouring one forgeable.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import logging
31
+ import os
32
+ from collections.abc import Callable
33
+ from typing import TYPE_CHECKING
34
+
35
+ import httpx
36
+ from cachetools import TTLCache
37
+ from fastapi import Depends, HTTPException, Request, Response, status
38
+ from starlette.middleware.base import BaseHTTPMiddleware
39
+
40
+ # Single source of truth for both bypass knobs — TRUSTED_CIDRS (which callers
41
+ # may skip authentication) and DEFAULT_TENANT_ID (which tenant they are then
42
+ # granted). Both used to be byte-identical copies of blocks in middleware.py, so
43
+ # a fix to one would have left the other permissive.
44
+ #
45
+ # Both names are re-exported here for backward compatibility. 1.7.0 moved
46
+ # TRUSTED_NETWORKS to ._trusted_networks and dropped it from this module — but
47
+ # coding-security (src/auth/tenant.py:22) and coding-testing
48
+ # (src/auth/tenant.py:16) import the name from HERE. Both wrap their
49
+ # auth-middleware import in
50
+ # `except ImportError: logger.warning(... running WITHOUT authentication ...)`,
51
+ # so removing it did not fail their build — it would have made them start with
52
+ # NO auth middleware at all, announced only as a warning. A back-compat shim is
53
+ # the difference between a lockfile bump being a security fix and a total
54
+ # unauthenticated-access regression. Keep both until every importer moves to
55
+ # `coding_client.auth._trusted_networks` / `._default_tenant`.
56
+ from ._default_tenant import (
57
+ DEFAULT_TENANT_ID, # noqa: F401
58
+ resolve_default_tenant,
59
+ )
60
+ from ._header_trust import trust_tenant_header
61
+ from ._onbehalf import resolve_tenant_assertion
62
+ from ._trusted_networks import TRUSTED_NETWORKS # noqa: F401
63
+ from ._trusted_networks import is_trusted_network as _is_trusted_network
64
+ from .models import User
65
+
66
+ if TYPE_CHECKING:
67
+ # TODO: service-specific import removed
68
+ # from src.app.core.tenant_context import TenantContext
69
+ pass
70
+
71
+ logger = logging.getLogger(__name__)
72
+
73
+ # Header name for tenant ID (set by Istio gateway after license validation)
74
+ TENANT_ID_HEADER = "X-Tenant-Id"
75
+
76
+ # License introspection caches.
77
+ #
78
+ # TWO objects, ONE implementation and ONE code path — cachetools gives a
79
+ # TTLCache a single ttl, and a granted decision and a refused one must expire on
80
+ # very different clocks. Everything that reads or writes either of them lives in
81
+ # `_introspect_license` below; use `clear_license_caches()` to empty them.
82
+ #
83
+ # Why the negative cache exists
84
+ # -----------------------------
85
+ # `_introspect_license` used to cache only when `data.get("active")`, so every
86
+ # refusal — revoked license, unknown token, licenses service down — was a fresh
87
+ # POST, and a fresh `httpx.AsyncClient`, therefore a fresh TCP connection, to
88
+ # `licenses:8544`. `licenses/main.py:820-827` calls
89
+ # that "the fleet's hottest auth path".
90
+ #
91
+ # That was harmless while nothing read the Authorization header. Once
92
+ # `AuthMiddleware._user_from_license` started introspecting (1.10.0), any
93
+ # unauthenticated caller that can reach a gate-off `coding-*` port became a 1:1
94
+ # amplifier into shared infrastructure: one garbage Bearer in, one POST out.
95
+ # Under a retry storm the amplified path is precisely the one every other
96
+ # service also needs to authenticate through.
97
+ #
98
+ # Why the TTLs are what they are
99
+ # ------------------------------
100
+ # 10s vs 300s. The negative window is the time a caller stays refused after
101
+ # their license is (re-)issued or the licenses service recovers, so it must be
102
+ # small. It is already strictly smaller than the risk accepted in the other
103
+ # direction: the positive cache honours a REVOKED license for up to 5 minutes.
104
+ # Non-zero, because zero is the 1:1 fan-out this exists to stop — 10s caps a hot
105
+ # loop at one outbound call per token per 10s.
106
+ #
107
+ # ⚠️ What this does NOT do, stated so the comment does not outrun the code: it
108
+ # dampens repeated presentations of the SAME token. A caller rotating tokens
109
+ # mints a fresh key every time and still fans out 1:1. The keys are therefore
110
+ # attacker-controlled, which is why `maxsize` is not optional — LRU eviction is
111
+ # the memory bound. Rate limiting at the edge is the answer to a rotating flood;
112
+ # this is not.
113
+ POSITIVE_CACHE_TTL_SECONDS = 300
114
+ NEGATIVE_CACHE_TTL_SECONDS = 10
115
+
116
+ _license_cache: TTLCache = TTLCache(maxsize=1000, ttl=POSITIVE_CACHE_TTL_SECONDS)
117
+ _negative_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=NEGATIVE_CACHE_TTL_SECONDS)
118
+
119
+
120
+ def clear_license_caches() -> None:
121
+ """Empty both introspection caches.
122
+
123
+ Tests must clear BOTH or a refusal cached by one test leaks into the next
124
+ and silently suppresses its outbound call. Provided as one function so no
125
+ caller has to remember that there are two objects.
126
+ """
127
+ _license_cache.clear()
128
+ _negative_license_cache.clear()
129
+
130
+
131
+ # LICENSE_INTROSPECT_URL is read from env at call time; production injects it
132
+ # via the Deployment env block (see the platform configuration policy).
133
+ #
134
+ # There is no default. When it is unset, introspection is SKIPPED and
135
+ # _introspect_license returns None — it does NOT raise. (An earlier comment here
136
+ # claimed "missing env raises at first introspection attempt"; it never did.
137
+ # Comment and behaviour disagreed, which is how three services ran without it
138
+ # unnoticed.) Skipping is now safe rather than permissive: with TRUSTED_CIDRS
139
+ # empty by default there is no *network* bypass left for the request to fall
140
+ # through to, and it is logged at ERROR below rather than WARNING.
141
+ #
142
+ # ⚠️ But this does NOT mean the request necessarily 401s. An earlier version of
143
+ # this comment claimed "there is no bypass left" — that is wrong, and the
144
+ # correction matters. `require_tenant` Priority 1/1b returns a bare, unverified
145
+ # `X-Tenant-Id` header BEFORE reaching license introspection at all, so on that
146
+ # path this ERROR is never even emitted. `require_tenant` is tenant *resolution*,
147
+ # not authentication; coding-github's routers/github_internal.py says the same.
148
+ # Header trust is only sound while the mesh restricts who can reach the port
149
+ # (gitops #2298 narrowed that for coding-*). Closing Priority 1b is tracked
150
+ # separately — do not read this module as "auth is now closed".
151
+ LICENSE_INTROSPECT_URL = os.getenv("LICENSE_INTROSPECT_URL", "")
152
+
153
+ # DEFAULT_TENANT_ID is re-exported above from ._default_tenant and is ``None``
154
+ # when unset — there is deliberately no literal default. It used to be
155
+ # ``os.getenv("DEFAULT_TENANT_ID", "n8n-dev")`` here AND, byte-identically, in
156
+ # middleware.py; see ._default_tenant for why that literal was a data-isolation
157
+ # bug rather than a harmless placeholder. Read it through
158
+ # ``resolve_default_tenant()``, never as a captured constant, so that "unset"
159
+ # means "reject the caller" at every site.
160
+
161
+
162
+ async def _introspect_license(token: str) -> dict | None:
163
+ """Introspect a license token to get tenant context.
164
+
165
+ Args:
166
+ token: The license token (from Authorization header).
167
+
168
+ Returns:
169
+ License data dict with 'active', 'tenant_id' (the tenant/workspace — what
170
+ X-Tenant-Id should carry) and 'subject_id' (the user; never the tenant), etc.
171
+ Returns None if introspection fails or token is invalid.
172
+
173
+ Both outcomes are cached — the grant for ``POSITIVE_CACHE_TTL_SECONDS``, the
174
+ refusal for the much shorter ``NEGATIVE_CACHE_TTL_SECONDS``. See the comment
175
+ above the two cache objects for why the refusal is cached at all.
176
+ """
177
+ cache_key = f"lic:{hashlib.sha256(token.encode()).hexdigest()[:16]}"
178
+ if cache_key in _license_cache:
179
+ return _license_cache[cache_key]
180
+ if cache_key in _negative_license_cache:
181
+ # The stored DECISION, replayed. Never "we already asked, fall through":
182
+ # a refusal that degrades into no-answer would turn a fan-out fix into
183
+ # an authentication bypass.
184
+ return _negative_license_cache[cache_key]
185
+
186
+ introspect_url = os.getenv("LICENSE_INTROSPECT_URL", LICENSE_INTROSPECT_URL)
187
+ if not introspect_url:
188
+ # ERROR, not WARNING: a license token was presented and we cannot verify
189
+ # it, so this request WILL 401. That is a misconfiguration causing an
190
+ # outage for every licensed caller, not a routine condition.
191
+ #
192
+ # Deliberately NOT negative-cached: no outbound call was made, so there
193
+ # is no amplification to dampen, and caching would only delay recovery
194
+ # once the env var is set.
195
+ logger.error(
196
+ "LICENSE_INTROSPECT_URL not set; cannot introspect the presented "
197
+ "license token, so this request will be rejected. Set it on the "
198
+ "Deployment env block."
199
+ )
200
+ return None
201
+
202
+ data: dict | None = None
203
+ try:
204
+ async with httpx.AsyncClient(timeout=2.0) as client:
205
+ resp = await client.post(introspect_url, json={"token": token})
206
+ if resp.status_code == 200:
207
+ parsed = resp.json()
208
+ # A 200 carrying something that is not an object is not a
209
+ # license. Guarded here so callers never meet a non-dict.
210
+ data = parsed if isinstance(parsed, dict) else None
211
+ except Exception as e:
212
+ logger.warning(f"License introspection failed: {e}")
213
+ data = None
214
+
215
+ if data is not None and data.get("active"):
216
+ _license_cache[cache_key] = data
217
+ else:
218
+ _negative_license_cache[cache_key] = data
219
+ return data
220
+
221
+
222
+ class TenantMiddleware(BaseHTTPMiddleware):
223
+ """Middleware to extract and validate tenant ID from headers.
224
+
225
+ This middleware runs after AuthMiddleware and:
226
+ 1. Extracts X-Tenant-Id header (set by Istio after /license/introspect)
227
+ 2. Updates the user's tenant_id if header is present
228
+ 3. Logs tenant context for auditing
229
+
230
+ Usage:
231
+ app.add_middleware(TenantMiddleware)
232
+ """
233
+
234
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
235
+ """Process request and extract tenant context."""
236
+ # Extract tenant ID from header.
237
+ # Gated on TRUST_TENANT_HEADER (see ._header_trust): when header trust is
238
+ # off, the header is treated as absent, so request.state.tenant_id is
239
+ # never populated from it and `require_tenant` falls through to license
240
+ # introspection instead of returning an unverified claim.
241
+ header_tenant_id = request.headers.get(TENANT_ID_HEADER) if trust_tenant_header() else None
242
+
243
+ # If user is authenticated, update their tenant_id from header
244
+ if hasattr(request.state, "user") and request.state.user:
245
+ user: User = request.state.user
246
+
247
+ if header_tenant_id:
248
+ # Header takes precedence (set by Istio after license validation)
249
+ user.tenant_id = header_tenant_id
250
+ logger.debug(
251
+ "Tenant ID set from header: %s for user %s",
252
+ header_tenant_id,
253
+ user.preferred_username,
254
+ )
255
+ elif not user.tenant_id:
256
+ # Fallback to user's sub if no tenant_id set
257
+ user.tenant_id = user.sub
258
+ logger.debug("Tenant ID defaulted to user sub: %s", user.tenant_id)
259
+
260
+ # Store effective tenant_id in request state for easy access
261
+ request.state.tenant_id = user.effective_tenant_id
262
+
263
+ elif header_tenant_id:
264
+ # No authenticated user but header present (service-to-service call)
265
+ request.state.tenant_id = header_tenant_id
266
+ logger.debug("Tenant ID from header (no user): %s", header_tenant_id)
267
+
268
+ return await call_next(request)
269
+
270
+
271
+ def get_tenant_id(request: Request) -> str | None:
272
+ """Get the current tenant ID from request state (sync version).
273
+
274
+ This dependency can be used in route handlers to get the tenant ID
275
+ for filtering database queries. For async with license introspection
276
+ fallback, use require_tenant instead.
277
+
278
+ Usage:
279
+ @router.get("/items")
280
+ async def list_items(tenant_id: str = Depends(get_tenant_id)):
281
+ return await db.get_items_by_tenant(tenant_id)
282
+
283
+ Returns:
284
+ The tenant ID or None if not set.
285
+ """
286
+ return getattr(request.state, "tenant_id", None)
287
+
288
+
289
+ async def require_tenant(request: Request) -> str:
290
+ """Require a tenant ID to be present with license introspection fallback.
291
+
292
+ Priority:
293
+ 1. X-Tenant-Id header (from ext_authz if it arrives through ztunnel)
294
+ 2. Authorization: Bearer <token> -> license introspection
295
+ 3. User from request.state (internal service-to-service)
296
+
297
+ Usage:
298
+ @router.get("/protected")
299
+ async def protected_endpoint(tenant_id: str = Depends(require_tenant)):
300
+ return {"tenant_id": tenant_id}
301
+
302
+ Returns:
303
+ The tenant ID.
304
+
305
+ Raises:
306
+ HTTPException: If no tenant ID is found (401 Unauthorized).
307
+ """
308
+ # Priority 1: From request.state (set by middleware from X-Tenant-Id header)
309
+ tenant_id = get_tenant_id(request)
310
+ if tenant_id:
311
+ return tenant_id
312
+
313
+ # Priority 1b: Direct X-Tenant-Id header (works without TenantMiddleware,
314
+ # e.g. when gateway forwards headers to a backend with ENABLE_AUTH=false).
315
+ #
316
+ # This is an UNVERIFIED claim and it sits above license introspection, so a
317
+ # forged header beats having no license. It is sound only where the mesh
318
+ # controls who can reach the port. Gated on TRUST_TENANT_HEADER; when off we
319
+ # skip the block entirely and fall through to Priority 2 — the request is
320
+ # NOT downgraded to DEFAULT_TENANT_ID.
321
+ if trust_tenant_header():
322
+ x_tenant_id = request.headers.get("x-tenant-id")
323
+ if x_tenant_id:
324
+ request.state.tenant_id = x_tenant_id
325
+ return x_tenant_id
326
+
327
+ # Priority 2: License introspection from Authorization header.
328
+ #
329
+ # The scheme test is case-INSENSITIVE and the token is stripped, matching
330
+ # AuthMiddleware (middleware.py). These used to disagree: this function
331
+ # required a literal "Bearer " and did not strip, so a lowercase `bearer`
332
+ # authenticated through the middleware but was invisible here, and a route
333
+ # using this dependency would 401 a request the middleware had just admitted.
334
+ auth_header = request.headers.get("Authorization", "")
335
+ if auth_header.lower().startswith("bearer "):
336
+ token = auth_header[7:].strip()
337
+ license_data = await _introspect_license(token)
338
+ if license_data and license_data.get("active"):
339
+ # `tenant_id` is the tenant (workspace/org) claim the licenses service
340
+ # returns for X-Tenant-Id injection; `subject_id` is the USER and must
341
+ # NOT be used for tenant scoping.
342
+ raw_tenant = license_data.get("tenant_id")
343
+ tenant_id = raw_tenant.strip() if isinstance(raw_tenant, str) else ""
344
+ if tenant_id:
345
+ # On-behalf-of, same rule as AuthMiddleware and through the same
346
+ # function so the two cannot drift. A service asserting another
347
+ # tenant is honoured only when its subject is allowlisted; an
348
+ # unvouched disagreement is a 403, not a silent downgrade to the
349
+ # caller's own tenant.
350
+ effective_tenant, refused = resolve_tenant_assertion(
351
+ request,
352
+ subject_id=(license_data.get("subject_id") or None),
353
+ token_tenant=tenant_id,
354
+ )
355
+ if refused:
356
+ raise HTTPException(
357
+ status_code=status.HTTP_403_FORBIDDEN,
358
+ detail=(
359
+ "This credential may not assert a tenant other than the one "
360
+ "its license names."
361
+ ),
362
+ )
363
+ logger.debug(f"Tenant ID from license introspection: {effective_tenant}")
364
+ # Cache in request state for subsequent calls
365
+ request.state.tenant_id = effective_tenant
366
+ return effective_tenant
367
+
368
+ # Priority 3: Fallback to user state
369
+ if hasattr(request.state, "user") and request.state.user:
370
+ user: User = request.state.user
371
+ tenant_id = user.effective_tenant_id
372
+ if tenant_id:
373
+ return tenant_id
374
+
375
+ # Priority 4: Trusted network bypass.
376
+ # Only usable when an operator configured BOTH the network (TRUSTED_CIDRS)
377
+ # and the tenant it grants (DEFAULT_TENANT_ID). With no tenant configured we
378
+ # fall through to the 401 below rather than inventing one.
379
+ client_host = request.client.host if request.client else None
380
+ if _is_trusted_network(client_host):
381
+ default_tenant = resolve_default_tenant("the trusted-network bypass in require_tenant")
382
+ if default_tenant is not None:
383
+ request.state.tenant_id = default_tenant
384
+ return default_tenant
385
+
386
+ raise HTTPException(
387
+ status_code=status.HTTP_401_UNAUTHORIZED,
388
+ detail="Tenant context required. Ensure X-Tenant-Id header is set or provide valid license token.",
389
+ )
390
+
391
+
392
+ async def optional_tenant(request: Request) -> str | None:
393
+ """Extract tenant ID if available, without requiring it.
394
+
395
+ Same priority chain as ``require_tenant`` but returns *None* instead
396
+ of raising 401 when no tenant context can be resolved. Use this for
397
+ proxy / pass-through routes where the backend service enforces its own
398
+ auth.
399
+ """
400
+ tenant_id = get_tenant_id(request)
401
+ if tenant_id:
402
+ return tenant_id
403
+
404
+ # Direct X-Tenant-Id header fallback (mirrors require_tenant priority 1b,
405
+ # including its TRUST_TENANT_HEADER gate — the two must not drift, or a
406
+ # route using optional_tenant would stay forgeable after the other closed)
407
+ if trust_tenant_header():
408
+ x_tenant_id = request.headers.get("x-tenant-id")
409
+ if x_tenant_id:
410
+ request.state.tenant_id = x_tenant_id
411
+ return x_tenant_id
412
+
413
+ # Case-insensitive scheme + stripped token, mirroring require_tenant and
414
+ # AuthMiddleware. See the comment there for what the divergence cost.
415
+ auth_header = request.headers.get("Authorization", "")
416
+ if auth_header.lower().startswith("bearer "):
417
+ token = auth_header[7:].strip()
418
+ license_data = await _introspect_license(token)
419
+ if license_data and license_data.get("active"):
420
+ # `tenant_id` is the tenant; `subject_id` is the USER (see require_tenant).
421
+ raw_tenant = license_data.get("tenant_id")
422
+ tid = raw_tenant.strip() if isinstance(raw_tenant, str) else ""
423
+ if tid:
424
+ # On-behalf-of, identical rule and identical function to
425
+ # require_tenant.
426
+ #
427
+ # A refused assertion RAISES here even though this function's
428
+ # job is to return None rather than raise. That promise is about
429
+ # the ABSENCE of tenant context — "nobody said who you are, carry
430
+ # on and let the backend decide". A refusal is a different
431
+ # answer: the caller said who it was acting for and may not.
432
+ # Returning None would drop that to "no tenant" and let a
433
+ # pass-through route continue on a default; returning the
434
+ # caller's own tenant would scope the customer's request to the
435
+ # service. Both silently reinterpret a denial as something else.
436
+ effective_tenant, refused = resolve_tenant_assertion(
437
+ request,
438
+ subject_id=(license_data.get("subject_id") or None),
439
+ token_tenant=tid,
440
+ )
441
+ if refused:
442
+ raise HTTPException(
443
+ status_code=status.HTTP_403_FORBIDDEN,
444
+ detail=(
445
+ "This credential may not assert a tenant other than the one "
446
+ "its license names."
447
+ ),
448
+ )
449
+ request.state.tenant_id = effective_tenant
450
+ return effective_tenant
451
+
452
+ if hasattr(request.state, "user") and request.state.user:
453
+ user: User = request.state.user
454
+ if tid := user.effective_tenant_id:
455
+ return tid
456
+
457
+ client_host = request.client.host if request.client else None
458
+ if _is_trusted_network(client_host):
459
+ default_tenant = resolve_default_tenant("the trusted-network bypass in optional_tenant")
460
+ if default_tenant is not None:
461
+ request.state.tenant_id = default_tenant
462
+ return default_tenant
463
+
464
+ return None
465
+
466
+
467
+ def get_current_user_with_tenant(request: Request) -> User:
468
+ """Get the current authenticated user with tenant context.
469
+
470
+ Works with or without AuthMiddleware:
471
+ 1. request.state.user (set by AuthMiddleware when enabled)
472
+ 2. X-Tenant-Id / X-User-Id headers (set by gateway proxy or Istio)
473
+ 3. Trusted network bypass with DEFAULT_TENANT_ID
474
+ """
475
+ # Priority 1: User already set by AuthMiddleware
476
+ if hasattr(request.state, "user") and request.state.user:
477
+ user: User = request.state.user
478
+ if not user.tenant_id:
479
+ user.tenant_id = user.sub
480
+ return user
481
+
482
+ # Priority 2: Construct user from forwarded headers (gateway proxy sets these).
483
+ #
484
+ # Gated on TRUST_TENANT_HEADER. Note this block trusts more than the tenant:
485
+ # `x-user-roles` becomes realm_access["roles"], which feeds User.is_admin(),
486
+ # which `require_same_tenant` honours as "may access ANY tenant". So an
487
+ # unverified `X-User-Roles: admin` is a privilege escalation on top of the
488
+ # tenant forgery, not merely a second instance of it.
489
+ #
490
+ # This function is sync and therefore cannot fall through to license
491
+ # introspection. With the gate off, a header-only caller reaches Priority 4
492
+ # (trusted network, empty by default) and then 401 — so a service that
493
+ # resolves identity ONLY through this dependency must have AuthMiddleware in
494
+ # front of it, or a real license path, before the knob is flipped.
495
+ if trust_tenant_header():
496
+ x_tenant_id = request.headers.get("x-tenant-id")
497
+ x_user_id = request.headers.get("x-user-id")
498
+ if x_tenant_id or x_user_id:
499
+ roles = [r for r in request.headers.get("x-user-roles", "").split(",") if r]
500
+ return User(
501
+ sub=x_user_id or "anonymous",
502
+ preferred_username=request.headers.get("x-user-name", "anonymous"),
503
+ email=request.headers.get("x-user-email"),
504
+ tenant_id=x_tenant_id,
505
+ organization_id=request.headers.get("x-organization-id"),
506
+ realm_access={"roles": roles},
507
+ )
508
+
509
+ # Priority 4: Trusted network bypass.
510
+ # NB the User must not be constructed with tenant_id=None as a "safe"
511
+ # degradation: User.model_post_init backfills a blank tenant_id from
512
+ # organization_id/sub, so a None here would quietly become a tenant named
513
+ # after the synthetic subject. Refuse to build the user at all instead.
514
+ client_host = request.client.host if request.client else None
515
+ if _is_trusted_network(client_host):
516
+ default_tenant = resolve_default_tenant(
517
+ "the trusted-network bypass in get_current_user_with_tenant"
518
+ )
519
+ if default_tenant is not None:
520
+ return User(
521
+ sub=default_tenant,
522
+ preferred_username="internal",
523
+ tenant_id=default_tenant,
524
+ )
525
+
526
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
527
+
528
+
529
+ def require_same_tenant(resource_tenant_id: str):
530
+ """Create a dependency that validates the user has access to a specific tenant.
531
+
532
+ This is used when accessing a specific resource to ensure the user
533
+ has permission to access that tenant's data.
534
+
535
+ Usage:
536
+ @router.get("/containers/{container_id}")
537
+ async def get_container(
538
+ container_id: str,
539
+ user: User = Depends(get_current_user_with_tenant)
540
+ ):
541
+ container = await db.get_container(container_id)
542
+ verify = require_same_tenant(container.tenant_id)
543
+ verify(user) # Raises if no access
544
+ return container
545
+
546
+ Args:
547
+ resource_tenant_id: The tenant_id of the resource being accessed.
548
+
549
+ Returns:
550
+ A validator function that checks user access.
551
+ """
552
+
553
+ def validator(user: User = Depends(get_current_user_with_tenant)) -> User:
554
+ # Admins can access any tenant
555
+ if user.is_admin():
556
+ return user
557
+
558
+ # Regular users can only access their own tenant
559
+ if user.effective_tenant_id != resource_tenant_id:
560
+ raise HTTPException(
561
+ status_code=status.HTTP_403_FORBIDDEN,
562
+ detail="Access denied: resource belongs to a different tenant",
563
+ )
564
+ return user
565
+
566
+ return validator
567
+
568
+
569
+ def check_tenant_access(user: User, resource_tenant_id: str) -> bool:
570
+ """Check if a user has access to a specific tenant's resources.
571
+
572
+ This is a utility function for use in business logic where you need
573
+ to check access without raising an exception.
574
+
575
+ Args:
576
+ user: The authenticated user.
577
+ resource_tenant_id: The tenant_id of the resource.
578
+
579
+ Returns:
580
+ True if user has access, False otherwise.
581
+ """
582
+ # Admins can access any tenant
583
+ if user.is_admin():
584
+ return True
585
+
586
+ # Regular users can only access their own tenant
587
+ return user.effective_tenant_id == resource_tenant_id
588
+
589
+
590
+ def create_tenant_context(request: Request):
591
+ """Create a TenantContext from the current request.
592
+
593
+ This combines the tenant_id and user information into a TenantContext
594
+ that can be passed to services and background tasks.
595
+
596
+ Usage:
597
+ @router.post("/analyze")
598
+ async def analyze(
599
+ request: Request,
600
+ background_tasks: BackgroundTasks,
601
+ tenant_id: str = Depends(require_tenant),
602
+ ):
603
+ ctx = create_tenant_context(request)
604
+ background_tasks.add_task(process_analysis, ctx=ctx, data=data)
605
+ return {"status": "queued"}
606
+
607
+ Args:
608
+ request: The FastAPI request object.
609
+
610
+ Returns:
611
+ TenantContext with tenant_id and user information.
612
+
613
+ Raises:
614
+ HTTPException: If no tenant context is available.
615
+ """
616
+ tenant_id = get_tenant_id(request)
617
+ if not tenant_id and trust_tenant_header():
618
+ # Same unverified claim as require_tenant Priority 1b, same gate.
619
+ # When the gate is off this is normally still fine: require_tenant runs
620
+ # first on these routes and has already written the license-resolved
621
+ # tenant into request.state, which get_tenant_id above returns.
622
+ tenant_id = request.headers.get("x-tenant-id")
623
+ if not tenant_id:
624
+ raise HTTPException(
625
+ status_code=status.HTTP_401_UNAUTHORIZED,
626
+ detail="Tenant context required",
627
+ )
628
+ return {"tenant_id": tenant_id}
629
+
630
+
631
+ def require_tenant_context(request: Request):
632
+ """FastAPI dependency that provides TenantContext.
633
+
634
+ This is a convenient dependency for routes that need the full context.
635
+
636
+ Usage:
637
+ @router.post("/process")
638
+ async def process(
639
+ ctx: TenantContext = Depends(require_tenant_context),
640
+ ):
641
+ result = await service.process(ctx.tenant_id, data)
642
+ ...
643
+
644
+ Args:
645
+ request: The FastAPI request object.
646
+
647
+ Returns:
648
+ TenantContext for the current request.
649
+ """
650
+ return create_tenant_context(request)