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,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")
|