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.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- 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)
|