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,61 @@
1
+ """FastAPI dependencies for authentication."""
2
+
3
+ from fastapi import HTTPException, Request, status
4
+
5
+ from .models import User
6
+
7
+
8
+ def get_current_user(request: Request) -> User:
9
+ """Get authenticated user from request.
10
+
11
+ Usage:
12
+ @app.get("/api/items")
13
+ async def get_items(user: User = Depends(get_current_user)):
14
+ return {"user": user.preferred_username}
15
+ """
16
+ if not hasattr(request.state, "user"):
17
+ raise HTTPException(
18
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
19
+ )
20
+ return request.state.user
21
+
22
+
23
+ def require_role(role: str):
24
+ """Require specific role.
25
+
26
+ Usage:
27
+ @app.post("/admin/endpoint")
28
+ async def admin_only(user: User = Depends(require_role("admin"))):
29
+ return {"message": "Admin access"}
30
+ """
31
+
32
+ def checker(request: Request) -> User:
33
+ user = get_current_user(request)
34
+ if not user.has_realm_role(role):
35
+ raise HTTPException(
36
+ status_code=status.HTTP_403_FORBIDDEN, detail=f"Role '{role}' required"
37
+ )
38
+ return user
39
+
40
+ return checker
41
+
42
+
43
+ def require_any_role(*roles: str):
44
+ """Require any of specified roles.
45
+
46
+ Usage:
47
+ @app.get("/editor/endpoint")
48
+ async def editor(user: User = Depends(require_any_role("editor", "admin"))):
49
+ return {"message": "Access granted"}
50
+ """
51
+
52
+ def checker(request: Request) -> User:
53
+ user = get_current_user(request)
54
+ if not any(user.has_realm_role(r) for r in roles):
55
+ raise HTTPException(
56
+ status_code=status.HTTP_403_FORBIDDEN,
57
+ detail=f"One of roles required: {', '.join(roles)}",
58
+ )
59
+ return user
60
+
61
+ return checker
@@ -0,0 +1,445 @@
1
+ """Authentication middleware - extracts user from Istio-forwarded headers.
2
+
3
+ Identity sources, in the order ``AuthMiddleware.dispatch`` tries them:
4
+
5
+ 1. ``DEV_API_KEY`` / ``TEST_API_KEY`` — only when ``ENVIRONMENT`` is
6
+ ``development`` or ``testing``. Never a production path.
7
+ 2. ``X-User-Id`` / ``X-Tenant-Id`` / ``X-User-Roles`` — unverified *claims*,
8
+ trustworthy only through the mesh edge. Gated on ``TRUST_TENANT_HEADER``
9
+ (see :mod:`._header_trust`).
10
+ 3. ``Authorization: Bearer <license>`` — verified by license introspection
11
+ against ``LICENSE_INTROSPECT_URL``. This is the only source in the list that
12
+ the library can actually *verify*.
13
+ 4. Trusted-network bypass: a source inside ``TRUSTED_CIDRS`` with none of the
14
+ above is allowed with a default tenant context. ``TRUSTED_CIDRS`` is **empty
15
+ by default** — see :mod:`._trusted_networks` for why.
16
+
17
+ Anything else is a 401.
18
+
19
+ ⚠️ Step 3 did not exist before 1.10.0, and its absence is what made
20
+ ``TRUST_TENANT_HEADER=false`` unusable. With the gate off, step 2 yields nothing
21
+ and the chain went straight to step 4 — which is empty on all nine ``coding-*``
22
+ services — so **every** authenticated request 401'd, including ones carrying a
23
+ perfectly valid license. coding-realtime hit exactly that live: it has the gate
24
+ off in ``coding-realtime-config`` and was rejecting coding-gateway's
25
+ ``GET /api/stats`` with "Unauthenticated request from untrusted network" while
26
+ the Bearer sat unread in the request. The edge EnvoyFilter strips nine identity
27
+ headers but never ``authorization``, and coding-gateway's ``_HOP_BY_HOP`` list
28
+ holds ``proxy-authorization`` but not ``authorization``, so the credential is
29
+ present at the pod. Nothing looked at it.
30
+
31
+ This module deliberately does NOT own an introspection implementation. It calls
32
+ :func:`.tenant._introspect_license` — same function, same caches: the 5-minute
33
+ positive one and the short negative one that keeps a rejected caller from being
34
+ a 1:1 amplifier into ``licenses`` (see the cache comment in ``tenant.py``). A
35
+ second copy of the rule is how ``TRUSTED_CIDRS`` and ``DEFAULT_TENANT_ID`` ended
36
+ up as byte-identical blocks here and in ``tenant.py``, where fixing one left the
37
+ other permissive.
38
+ """
39
+
40
+ import logging
41
+ import os
42
+
43
+ from starlette.middleware.base import BaseHTTPMiddleware
44
+ from starlette.requests import Request
45
+ from starlette.responses import JSONResponse
46
+
47
+ # Single source of truth — TRUSTED_CIDRS and DEFAULT_TENANT_ID used to be
48
+ # byte-identical copies of the blocks in tenant.py, so a fix to one would have
49
+ # left the other permissive. DEFAULT_TENANT_ID is re-exported for back-compat.
50
+ from ._default_tenant import (
51
+ DEFAULT_TENANT_ID, # noqa: F401
52
+ resolve_default_tenant,
53
+ )
54
+ from ._header_trust import trust_tenant_header
55
+ from ._onbehalf import resolve_tenant_assertion
56
+ from ._trusted_networks import TRUSTED_NETWORKS
57
+ from ._trusted_networks import is_trusted_network as _is_trusted_network
58
+ from .models import User
59
+
60
+ # The one introspection implementation and the one cache. Imported rather than
61
+ # reimplemented — see the module docstring. Importing it here also means the
62
+ # existing `patch("coding_client.auth.tenant.httpx.AsyncClient")` test double
63
+ # keeps intercepting the middleware path too: the call still executes in
64
+ # tenant.py's namespace.
65
+ #
66
+ # Who relies on that, measured 2026-10-01 with
67
+ # `git grep -c 'coding_client.auth.tenant.httpx' origin/main -- '*.py'` over
68
+ # every coding-* repo (an earlier revision of this comment named "the conftests
69
+ # of coding-testing / coding-ai / coding-analysis", which was wrong):
70
+ # coding-analysis tests/conftest.py, tests/unit/test_auth_middleware_and_deps.py,
71
+ # tests/unit/test_ws_identity.py
72
+ # coding-realtime tests/conftest.py, tests/unit/test_ws_identity.py
73
+ # coding-testing tests/unit/test_auth.py, tests/unit/test_middleware.py,
74
+ # tests/unit/test_fork_vouching.py
75
+ # coding-ai has ZERO: it patches `coding_client.auth.middleware._introspect_license`
76
+ # itself (tests/conftest.py, tests/unit/test_coverage_gaps.py,
77
+ # tests/unit/test_tenant_header_trust.py) — a double of the FUNCTION's binding
78
+ # in THIS module, so it never reaches tenant.py's httpx call. Re-measure before
79
+ # trusting a list.
80
+ from .tenant import _introspect_license
81
+
82
+ __all__ = ["AuthMiddleware", "PUBLIC_PATHS", "TRUSTED_NETWORKS"]
83
+
84
+ logger = logging.getLogger(__name__)
85
+
86
+ # DEFAULT_TENANT_ID is re-exported above from ._default_tenant and is ``None``
87
+ # when unset. It used to be ``os.getenv("DEFAULT_TENANT_ID", "n8n-dev")`` here,
88
+ # a byte-identical copy of the block in tenant.py — so the synthetic users
89
+ # minted below carried a tenant literal that nobody configured. Always go
90
+ # through ``resolve_default_tenant()``: unset means "do not mint a user".
91
+
92
+
93
+ # Paths that do not require authentication
94
+ PUBLIC_PATHS = frozenset(
95
+ {
96
+ "/",
97
+ "/health",
98
+ "/health/",
99
+ "/health/ready",
100
+ "/health/live",
101
+ "/ready",
102
+ "/metrics",
103
+ "/openapi.json",
104
+ "/docs",
105
+ "/docs/",
106
+ "/redoc",
107
+ }
108
+ )
109
+
110
+
111
+ async def _user_from_license(request: Request) -> User | None:
112
+ """Mint a :class:`User` from a verified ``Authorization: Bearer <license>``.
113
+
114
+ Returns ``None`` — never raises, never a partially-trusted user — whenever
115
+ the request cannot be shown to carry a live license. Every ``None`` below
116
+ ends as a 401 in :meth:`AuthMiddleware.dispatch`, so the failure modes are
117
+ deliberately indistinguishable to the caller:
118
+
119
+ * no ``Authorization`` header, or one that is not a Bearer;
120
+ * an empty token after the scheme;
121
+ * ``LICENSE_INTROSPECT_URL`` unset — ``_introspect_license`` logs at ERROR
122
+ and returns ``None`` rather than raising (tenant.py:81-83). An unset URL
123
+ means "this path yields no identity". It must never mean "admit", and it
124
+ must never mean "crash": a misconfigured Deployment is an outage for
125
+ licensed callers, not a hole for unlicensed ones;
126
+ * introspection failed, timed out, or the endpoint was unreachable —
127
+ ``_introspect_license`` already swallows those into ``None``; the
128
+ ``except`` below is the belt to that braces, so a raising introspection
129
+ is a 401 and not a 500;
130
+ * the license is inactive/expired (``active`` falsy);
131
+ * the license body is malformed — a non-dict, or a ``tenant_id`` /
132
+ ``subject_id`` that is not a string. See :func:`_user_from_license_data`;
133
+ * the license is live but carries no usable ``tenant_id``. There is nothing
134
+ to scope the caller's data to, and ``User.model_post_init`` would silently
135
+ backfill the tenant from ``sub`` — a tenant named after the subject. Nine
136
+ services would then read and write under it. Reject instead.
137
+
138
+ ⚠️ The ``try`` below covers the whole parse, not just the HTTP call. The
139
+ first revision of this function wrapped ``_introspect_license`` alone and
140
+ left the ``User(...)`` build outside it, so a ``tenant_id`` of ``123`` raised
141
+ a pydantic ``ValidationError`` straight out of ``dispatch`` as a **500** —
142
+ while this docstring's first line claimed the function never raises. Whoever
143
+ influences what the introspection endpoint returns must not get a liveness
144
+ lever, and a 500 is not a rejection anybody audits.
145
+
146
+ This runs *below* the identity-header block on purpose, mirroring the
147
+ precedence chain documented in :mod:`.tenant`: header first (when trusted),
148
+ license second. So with ``TRUST_TENANT_HEADER`` unset or true, a
149
+ header-bearing request is served exactly as it was before 1.10.0 and this
150
+ function is never reached — the change is additive, not a re-ordering.
151
+ """
152
+ auth_header = request.headers.get("authorization", "")
153
+ if not auth_header.lower().startswith("bearer "):
154
+ return None
155
+
156
+ token = auth_header[7:].strip()
157
+ if not token:
158
+ return None
159
+
160
+ try:
161
+ license_data = await _introspect_license(token)
162
+ return _user_from_license_data(license_data)
163
+ except Exception as exc:
164
+ # Fail CLOSED. _introspect_license contains its own try/except around
165
+ # the HTTP call, and _user_from_license_data type-checks every field it
166
+ # feeds to the model, so reaching here means something we did not
167
+ # anticipate — which is exactly when a 500 would be worst. The
168
+ # `_user_from_license_data` call is INSIDE this block on purpose: that
169
+ # is the structural guarantee, not the type checks, which only cover the
170
+ # shapes `User` rejects today.
171
+ logger.warning("License introspection raised; rejecting request: %s", exc)
172
+ return None
173
+
174
+
175
+ def _user_from_license_data(license_data: dict | None) -> User | None:
176
+ """Turn an introspection body into a :class:`User`, or ``None`` to reject.
177
+
178
+ Split out of :func:`_user_from_license` so the whole parse — including the
179
+ model construction — runs inside that function's ``try``.
180
+
181
+ The annotation is ``_introspect_license``'s declared return type; the
182
+ ``isinstance`` below is the runtime belt, because that declaration is only
183
+ as good as ``resp.json()`` — a licenses endpoint answering ``200`` with a
184
+ bare string or list would otherwise be an ``AttributeError`` on ``.get``.
185
+
186
+ Every field is type-checked before it reaches the model. ``User.tenant_id``
187
+ is ``str | None`` and ``User.sub`` is a required ``str``, so a non-string in
188
+ either slot is a pydantic ``ValidationError``; and pydantic's lax mode would
189
+ silently coerce ``b"subject"`` into ``"subject"``, minting a subject nobody
190
+ issued. Neither is a shape the licenses service can produce — its
191
+ ``TokenIntrospectResponse`` types both as ``str | None`` — so anything else
192
+ means we are not talking to the licenses service, and the safe reading of
193
+ that is "no identity".
194
+ """
195
+ if not isinstance(license_data, dict) or not license_data.get("active"):
196
+ return None
197
+
198
+ # `tenant_id` is the tenant (workspace/org) the licenses service resolves
199
+ # for X-Tenant-Id injection. `subject_id` is the USER and must NEVER be used
200
+ # for tenant scoping — the same distinction tenant.py:264-266 pins down.
201
+ #
202
+ # STRIP before testing. `if not tenant_id:` alone was a hole: " " is
203
+ # truthy, so it passed this guard, and then models.py:40 does
204
+ # `if not (self.tenant_id or "").strip()` and backfilled the tenant from
205
+ # `sub`. A payload of {"active": true, "tenant_id": " ",
206
+ # "subject_id": "attacker-sub"} was served 200 with tenant_id
207
+ # "attacker-sub" — the guard's own failure mode, reached through the guard.
208
+ # `.strip()` is the repo-wide convention for this: _default_tenant.
209
+ # load_default_tenant_id() and models.py:40 both use it, and it treats
210
+ # U+00A0 NO-BREAK SPACE as whitespace like the rest of them.
211
+ raw_tenant = license_data.get("tenant_id")
212
+ tenant_id = raw_tenant.strip() if isinstance(raw_tenant, str) else ""
213
+ if not tenant_id:
214
+ logger.warning(
215
+ "License is active but carries no usable tenant_id (type=%s); rejecting "
216
+ "rather than letting the tenant be backfilled from the subject.",
217
+ type(raw_tenant).__name__,
218
+ )
219
+ return None
220
+
221
+ # A missing/blank subject is normal and handled below with a marker. A
222
+ # subject of the WRONG TYPE is not: it means the body is not the shape we
223
+ # understand, so reject rather than coerce.
224
+ raw_subject = license_data.get("subject_id")
225
+ if raw_subject is not None and not isinstance(raw_subject, str):
226
+ logger.warning(
227
+ "License introspection returned a non-string subject_id (type=%s); "
228
+ "rejecting rather than coercing it into the `sub` slot.",
229
+ type(raw_subject).__name__,
230
+ )
231
+ return None
232
+
233
+ # Roles: a licensed caller gets NO roles today, and that is correct.
234
+ #
235
+ # Measured, not assumed — the licenses service's introspect response is a
236
+ # Pydantic `TokenIntrospectResponse` (licenses/main.py:497-512), so extra
237
+ # keys are stripped on the wire, and the model has no `roles`, `scopes`,
238
+ # `permissions`, `realm_access` or `entitlements` field. Its only
239
+ # entitlement-shaped members are rate limits and quotas under `plan`. A
240
+ # license says WHICH TENANT you are, never WHAT YOU MAY DO. The fleet's own
241
+ # ext_authz agrees: license-authz builds `x-caller-roles` from a separate
242
+ # Keycloak service-account JWT (license-authz/src/main.py:890-947), never
243
+ # from the introspection body.
244
+ #
245
+ # So the read below is a tolerant parse, NOT a promise that roles arrive.
246
+ # It is written down because two services already got this wrong in the
247
+ # other direction — mlx_ingestion_backend/src/auth/middleware.py:193-199 and
248
+ # scientific_processor/src/auth/middleware.py:410-420 both do
249
+ # `data.get("roles", [])` under a docstring claiming "roles come only from
250
+ # an introspected license". They are permanently empty and the docstring is
251
+ # false. Here the empty case is the DOCUMENTED one: no roles means
252
+ # `User.is_admin()` is False and `require_same_tenant` confines the caller
253
+ # to the tenant its license named, which is the outcome we want. Never
254
+ # substitute a default like ["user"] — that would hand a capability to
255
+ # every licensed caller that no authority granted.
256
+ raw_roles = license_data.get("roles")
257
+ roles = (
258
+ [r for r in raw_roles if isinstance(r, str) and r] if isinstance(raw_roles, list) else []
259
+ )
260
+
261
+ # `sub` is required by the model and is consulted by
262
+ # `User.model_post_init` / `effective_tenant_id` as a LAST-RESORT tenant. It
263
+ # cannot be left blank. When the license names no subject we use a marker
264
+ # that is obviously not a Keycloak subject id (which is a bare uuid4 — never
265
+ # prefixed, never containing a colon), so it can never be mistaken for one
266
+ # downstream; `tenant_id` above is non-blank AFTER stripping, so this value
267
+ # never reaches the tenant slot regardless. The `license:` prefix is the
268
+ # asserted contract, not decoration — see
269
+ # test_missing_subject_id_uses_the_license_marker_not_an_invented_subject.
270
+ subject_id = (raw_subject or "").strip() or f"license:{tenant_id}"
271
+
272
+ logger.debug("Authenticated via license introspection: tenant=%s", tenant_id)
273
+ return User(
274
+ sub=subject_id,
275
+ preferred_username=subject_id,
276
+ tenant_id=tenant_id,
277
+ realm_access={"roles": roles},
278
+ )
279
+
280
+
281
+ class AuthMiddleware(BaseHTTPMiddleware):
282
+ """Extract user info from Istio-forwarded headers or API key."""
283
+
284
+ async def dispatch(self, request: Request, call_next):
285
+ # Skip auth for public endpoints
286
+ if request.url.path in PUBLIC_PATHS:
287
+ return await call_next(request)
288
+
289
+ user = None
290
+
291
+ # Check for development mode API key authentication
292
+ environment = os.getenv("ENVIRONMENT", "production")
293
+ is_dev_or_test = environment in ("development", "testing")
294
+
295
+ if is_dev_or_test:
296
+ # Check for Bearer token in Authorization header
297
+ auth_header = request.headers.get("authorization", "")
298
+ if auth_header.lower().startswith("bearer "):
299
+ token = auth_header[7:].strip()
300
+ dev_api_key = os.getenv("DEV_API_KEY", "")
301
+ test_api_key = os.getenv("TEST_API_KEY", "")
302
+
303
+ if (dev_api_key and token == dev_api_key) or (
304
+ test_api_key and token == test_api_key
305
+ ):
306
+ # The dev API key proves nothing about *which tenant* the
307
+ # caller is. Without a configured DEFAULT_TENANT_ID there is
308
+ # no answer, so the key does not authenticate: `user` stays
309
+ # None and the request falls through to the header/trusted-
310
+ # network branches and ultimately to a 401.
311
+ dev_tenant = resolve_default_tenant(
312
+ f"{environment}-mode API key authentication"
313
+ )
314
+ if dev_tenant is not None:
315
+ logger.debug("API key authentication successful in %s mode", environment)
316
+ user = User(
317
+ sub="dev-user",
318
+ preferred_username="dev-user",
319
+ email="dev@example.com",
320
+ tenant_id=dev_tenant,
321
+ organization_id="dev-org",
322
+ realm_access={"roles": ["admin", "developer"]},
323
+ )
324
+
325
+ # Fall back to Istio headers if no API key auth.
326
+ #
327
+ # "Istio headers" is the optimistic name for them. At the mesh edge they
328
+ # are injected by ext_authz from a validated license and are genuinely
329
+ # trustworthy; pod-to-pod they are whatever the caller typed, and this
330
+ # block turns them into an authenticated User — tenant AND roles. Gated
331
+ # on TRUST_TENANT_HEADER (see ._header_trust); with the gate off the
332
+ # request is treated as carrying no identity at all and falls through to
333
+ # the trusted-network check and then a 401.
334
+ if user is None:
335
+ trust_headers = trust_tenant_header()
336
+ roles = (
337
+ [r for r in request.headers.get("x-user-roles", "").split(",") if r]
338
+ if trust_headers
339
+ else []
340
+ )
341
+ x_user_id = request.headers.get("x-user-id") if trust_headers else None
342
+ x_tenant_id = request.headers.get("x-tenant-id") if trust_headers else None
343
+
344
+ if x_user_id or x_tenant_id:
345
+ user = User(
346
+ sub=x_user_id or "anonymous",
347
+ preferred_username=request.headers.get("x-user-name", "anonymous"),
348
+ email=request.headers.get("x-user-email"),
349
+ tenant_id=x_tenant_id,
350
+ organization_id=request.headers.get("x-organization-id"),
351
+ realm_access={"roles": roles},
352
+ )
353
+
354
+ # Fall back to license introspection — the only VERIFIED identity source
355
+ # in this chain (see _user_from_license and the module docstring).
356
+ #
357
+ # This block is why the whole file changed. It used to be the `else:` of
358
+ # the header check above, meaning: no identity header => straight to the
359
+ # trusted-network bypass. With TRUST_TENANT_HEADER=false that made the
360
+ # header branch unreachable AND left nothing between it and a bypass
361
+ # that is disabled on every service, so a valid Bearer 401'd. Ordering
362
+ # matters both ways: it sits BELOW the header block so nothing changes
363
+ # while the gate is on, and ABOVE the network bypass so a real license
364
+ # beats a CIDR guess.
365
+ if user is None:
366
+ user = await _user_from_license(request)
367
+
368
+ # On-behalf-of. A service acting for a customer presents its OWN
369
+ # license and names the customer in a plain X-Tenant-Id; the
370
+ # assertion is honoured only when the license's subject is
371
+ # allowlisted (see ._onbehalf for the disagreement rule and why it
372
+ # is not keyed on the header's mere presence).
373
+ #
374
+ # This sits INSIDE the license branch, not below it as a fifth arm.
375
+ # Below, it would be unreachable: _user_from_license has already
376
+ # succeeded and minted a User scoped to the SERVICE's own tenant, so
377
+ # a later arm would never run and every S2S call would silently
378
+ # resolve to the caller's tenant instead of the customer's — the
379
+ # data-scoping bug this feature exists to avoid, shipped as the fix.
380
+ #
381
+ # `user.sub` IS the introspected subject_id (or the `license:`
382
+ # marker, which ._onbehalf refuses), and `user.tenant_id` is the
383
+ # tenant the token names, so no second introspection is needed.
384
+ if user is not None:
385
+ effective_tenant, refused = resolve_tenant_assertion(
386
+ request,
387
+ subject_id=user.sub,
388
+ token_tenant=user.tenant_id or "",
389
+ )
390
+ if refused:
391
+ return JSONResponse(
392
+ status_code=403,
393
+ content={
394
+ "error": "TENANT_ASSERTION_NOT_PERMITTED",
395
+ "detail": (
396
+ "This credential may not assert a tenant other than the "
397
+ "one its license names."
398
+ ),
399
+ },
400
+ )
401
+ if effective_tenant != user.tenant_id:
402
+ # `effective_tenant_id` is a read-time property over
403
+ # `tenant_id`, so replacing that one field is enough and no
404
+ # re-derivation is needed. `model_copy` rather than mutation
405
+ # so a caller holding the old object keeps seeing the tenant
406
+ # it was authenticated as.
407
+ user = user.model_copy(update={"tenant_id": effective_tenant})
408
+ # The audit trail (`vouching_subject_id`, `license_id`) and
409
+ # the `tenant_vouched` flag are set by
410
+ # `resolve_tenant_assertion` itself, on every path — this arm
411
+ # used to set them here and the other two resolvers did not,
412
+ # so a WebSocket route (which only ever runs `require_tenant`)
413
+ # carried no attribution at all.
414
+
415
+ if user is None:
416
+ # No identity from any verifiable source - check trusted network.
417
+ # The bypass needs BOTH a configured network and a configured tenant
418
+ # to grant; with no DEFAULT_TENANT_ID we reject rather than mint a
419
+ # synthetic user against a literal nobody chose.
420
+ client_host = request.client.host if request.client else None
421
+ default_tenant = (
422
+ resolve_default_tenant("the trusted-network bypass in AuthMiddleware")
423
+ if _is_trusted_network(client_host)
424
+ else None
425
+ )
426
+ if default_tenant is not None:
427
+ user = User(
428
+ sub=default_tenant,
429
+ preferred_username="internal",
430
+ tenant_id=default_tenant,
431
+ )
432
+ else:
433
+ # Untrusted network (or no tenant configured) - reject
434
+ logger.warning(
435
+ "Unauthenticated request from untrusted network: %s %s",
436
+ request.method,
437
+ request.url.path,
438
+ )
439
+ return JSONResponse(
440
+ status_code=401,
441
+ content={"detail": "Authentication required"},
442
+ )
443
+
444
+ request.state.user = user
445
+ return await call_next(request)
@@ -0,0 +1,83 @@
1
+ """User models for header-based auth with tenant isolation support."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class User(BaseModel):
7
+ """User model with header-based auth and tenant isolation.
8
+
9
+ Tenant isolation is provided via the tenant_id field, which can be:
10
+ 1. Set from X-User-Id and X-Tenant-Id headers (set by Istio gateway)
11
+ 2. Defaults to user's sub (user ID) for single-user tenants
12
+ """
13
+
14
+ sub: str = Field(..., description="User ID")
15
+ preferred_username: str = Field(..., description="Username")
16
+ email: str | None = None
17
+ email_verified: bool = False
18
+ name: str | None = None
19
+
20
+ # Roles from X-User-Roles header
21
+ realm_access: dict = Field(default_factory=dict)
22
+ resource_access: dict = Field(default_factory=dict)
23
+
24
+ # Tenant isolation - from X-Tenant-Id header
25
+ tenant_id: str | None = Field(default=None, description="Tenant identifier for data isolation")
26
+
27
+ # Optional organization fields
28
+ organization_id: str | None = Field(default=None, description="Organization ID")
29
+
30
+ def model_post_init(self, __context) -> None:
31
+ """Ensure tenant_id is set after model initialization.
32
+
33
+ An empty or whitespace-only ``tenant_id`` is treated the same as
34
+ ``None``. Upstream services forward ``x-tenant-id: ""`` when a license
35
+ token carries no tenant (e.g. the gateway's ``user.tenant_id or ""``),
36
+ and a blank value must NOT pin the user to an empty tenant — that
37
+ silently breaks data isolation. Fall back to ``organization_id`` then
38
+ ``sub`` instead.
39
+ """
40
+ if not (self.tenant_id or "").strip():
41
+ # Priority: organization_id > sub (user ID as fallback tenant)
42
+ self.tenant_id = self.organization_id or self.sub
43
+
44
+ @property
45
+ def effective_tenant_id(self) -> str:
46
+ """Get the effective tenant ID for data filtering.
47
+
48
+ Returns:
49
+ The tenant_id, organization_id, or sub as fallback.
50
+ """
51
+ return self.tenant_id or self.organization_id or self.sub
52
+
53
+ @property
54
+ def roles(self) -> list[str]:
55
+ """Realm roles as a flat list.
56
+
57
+ Bridge accessor so callers can read ``user.roles`` directly instead of
58
+ reaching into ``realm_access["roles"]``. Mirrors the realm-role half of
59
+ :meth:`get_all_roles`.
60
+ """
61
+ return self.realm_access.get("roles", [])
62
+
63
+ def has_realm_role(self, role: str) -> bool:
64
+ """Check if user has realm role."""
65
+ roles = self.realm_access.get("roles", [])
66
+ return role in roles
67
+
68
+ def has_client_role(self, client_id: str, role: str) -> bool:
69
+ """Check if user has client role."""
70
+ client_roles = self.resource_access.get(client_id, {}).get("roles", [])
71
+ return role in client_roles
72
+
73
+ def get_all_roles(self) -> list[str]:
74
+ """Get all roles (realm + client)."""
75
+ realm_roles = self.realm_access.get("roles", [])
76
+ client_roles = []
77
+ for client_data in self.resource_access.values():
78
+ client_roles.extend(client_data.get("roles", []))
79
+ return realm_roles + client_roles
80
+
81
+ def is_admin(self) -> bool:
82
+ """Check if user has admin privileges (can access all tenants)."""
83
+ return self.has_realm_role("admin") or self.has_realm_role("platform-admin")