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,132 @@
|
|
|
1
|
+
"""Resolution of the fallback tenant handed to credential-less callers.
|
|
2
|
+
|
|
3
|
+
``DEFAULT_TENANT_ID`` is the tenant granted to a caller that presented **no
|
|
4
|
+
credential** and was admitted anyway — the ``TRUSTED_CIDRS`` bypass in
|
|
5
|
+
:mod:`._trusted_networks`, and the development/testing API-key path in
|
|
6
|
+
:mod:`.middleware`. It is therefore a data-isolation key chosen by
|
|
7
|
+
configuration, not by the caller.
|
|
8
|
+
|
|
9
|
+
Why there is no default value (changed 2026-07-30)
|
|
10
|
+
--------------------------------------------------
|
|
11
|
+
This module-level constant used to be::
|
|
12
|
+
|
|
13
|
+
DEFAULT_TENANT_ID = os.getenv("DEFAULT_TENANT_ID", "n8n-dev")
|
|
14
|
+
|
|
15
|
+
so a service that never set the variable minted users against the literal
|
|
16
|
+
string ``"n8n-dev"``. That literal is not junk and it is not a real tenant_id
|
|
17
|
+
either, which is exactly what made it dangerous:
|
|
18
|
+
|
|
19
|
+
* ``n8n-dev`` **was** a real tenant — as a *text* tenant_id, until the
|
|
20
|
+
2026-04-29 Keycloak migration moved tenant identity to uuid7 and demoted the
|
|
21
|
+
label to a separate ``tenant_name`` attribute. The canonical id for that
|
|
22
|
+
tenant is now ``0195f48b-d000-7000-8000-000000000001`` (see
|
|
23
|
+
``the platform's Keycloak realm patch configmap``
|
|
24
|
+
``TENANT_UUIDS``).
|
|
25
|
+
* So the fallback pointed at the *stale label form* of a tenant that really
|
|
26
|
+
exists. Rows written under it land in a silo that no live JWT can ever
|
|
27
|
+
address, and reads under it silently cross out of whatever tenant the
|
|
28
|
+
operator believed the service was serving.
|
|
29
|
+
|
|
30
|
+
Found live on ``coding-repo-jobs``, which sets no ``DEFAULT_TENANT_ID``:
|
|
31
|
+
credential-less callers were reading, and could enqueue, against ``"n8n-dev"``.
|
|
32
|
+
|
|
33
|
+
There is no safe literal to pick here. The value that would be "consistent"
|
|
34
|
+
with coding-analysis / coding-security (``...0001``) is a *different* tenant
|
|
35
|
+
from coding-repo-jobs' own ``WORKER_TENANT_ID`` (``...0003``), so any built-in
|
|
36
|
+
default is a guess about someone else's data isolation. A missing tenant is
|
|
37
|
+
therefore an error: unset means the credential-less paths refuse to mint a
|
|
38
|
+
user at all, and — when a bypass is actually armed in production — the process
|
|
39
|
+
refuses to boot.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
|
|
47
|
+
from ._trusted_networks import load_trusted_networks
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
ENV_VAR = "DEFAULT_TENANT_ID"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_default_tenant_id() -> str | None:
|
|
55
|
+
"""Read ``DEFAULT_TENANT_ID`` from the environment. **No default value.**
|
|
56
|
+
|
|
57
|
+
An empty or whitespace-only value is treated the same as unset, mirroring
|
|
58
|
+
:meth:`coding_client.auth.models.User.model_post_init` — a blank tenant must
|
|
59
|
+
never pin a caller to an empty tenant.
|
|
60
|
+
"""
|
|
61
|
+
return os.getenv(ENV_VAR, "").strip() or None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
DEFAULT_TENANT_ID: str | None = load_default_tenant_id()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_default_tenant(purpose: str) -> str | None:
|
|
68
|
+
"""Return the configured fallback tenant, or ``None`` — never a literal.
|
|
69
|
+
|
|
70
|
+
Callers MUST treat ``None`` as "reject this request". Substituting any
|
|
71
|
+
made-up value here is the bug this module exists to prevent.
|
|
72
|
+
|
|
73
|
+
Reads the module attribute at call time (not a captured copy) so tests can
|
|
74
|
+
monkeypatch it, matching :func:`._trusted_networks.is_trusted_network`.
|
|
75
|
+
"""
|
|
76
|
+
tenant_id = DEFAULT_TENANT_ID
|
|
77
|
+
if tenant_id is None:
|
|
78
|
+
logger.error(
|
|
79
|
+
"%s is not configured; refusing to invent a tenant for %s. This "
|
|
80
|
+
"caller will be rejected. If this path is meant to be usable, set "
|
|
81
|
+
"%s on the Deployment env block to the tenant it should act as "
|
|
82
|
+
"(see the platform configuration policy).",
|
|
83
|
+
ENV_VAR,
|
|
84
|
+
purpose,
|
|
85
|
+
ENV_VAR,
|
|
86
|
+
)
|
|
87
|
+
return tenant_id
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def enforce_default_tenant_configured() -> None:
|
|
91
|
+
"""Crash boot when a credential-less bypass is armed with no tenant to grant.
|
|
92
|
+
|
|
93
|
+
Follows the repo's fail-loud-in-production convention (see
|
|
94
|
+
:mod:`coding_client.config.base_settings`): a misconfiguration must be
|
|
95
|
+
visible at deploy time, not at 3am on one unlucky request.
|
|
96
|
+
|
|
97
|
+
The gate is deliberately narrow — it fires only when *all* of:
|
|
98
|
+
|
|
99
|
+
1. ``ENVIRONMENT`` is ``production`` (the default), and
|
|
100
|
+
2. ``TRUSTED_CIDRS`` is non-empty, i.e. an operator has armed the
|
|
101
|
+
credential-less network bypass, and
|
|
102
|
+
3. ``DEFAULT_TENANT_ID`` is unset.
|
|
103
|
+
|
|
104
|
+
Condition 2 is what keeps this from being a fleet-wide breaking change:
|
|
105
|
+
``TRUSTED_CIDRS`` has been empty by default since 1.7.0, and no
|
|
106
|
+
``coding-client`` consumer sets a non-empty one, so today this raises for
|
|
107
|
+
nobody. It exists so that *re-widening* ``TRUSTED_CIDRS`` without naming the
|
|
108
|
+
tenant fails the rollout instead of silently reviving the literal.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
RuntimeError: never ``ImportError`` — coding-security and coding-testing
|
|
112
|
+
wrap their auth-middleware import in ``except ImportError`` and log
|
|
113
|
+
"running WITHOUT authentication", so an ``ImportError`` here would be
|
|
114
|
+
swallowed into exactly the outcome this check is meant to prevent.
|
|
115
|
+
"""
|
|
116
|
+
if os.getenv("ENVIRONMENT", "production") != "production":
|
|
117
|
+
return
|
|
118
|
+
trusted = load_trusted_networks()
|
|
119
|
+
if not trusted:
|
|
120
|
+
return
|
|
121
|
+
if load_default_tenant_id() is not None:
|
|
122
|
+
return
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
f"TRUSTED_CIDRS is set to {[str(n) for n in trusted]} — requests from "
|
|
125
|
+
f"those networks are admitted with NO credential — but {ENV_VAR} is not "
|
|
126
|
+
f"set, so there is no tenant to grant them. Refusing to start rather "
|
|
127
|
+
f"than serve a made-up tenant. Either set {ENV_VAR} on the Deployment "
|
|
128
|
+
f"env block, or clear TRUSTED_CIDRS to disable the bypass."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
enforce_default_tenant_configured()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Whether client-supplied identity headers may be believed.
|
|
2
|
+
|
|
3
|
+
``X-Tenant-Id`` (and its companions ``X-User-Id`` / ``X-User-Roles``) are
|
|
4
|
+
*claims*, not credentials. Nothing in this library can verify them. Whether they
|
|
5
|
+
are trustworthy is a property of the **network path**, not of the request:
|
|
6
|
+
|
|
7
|
+
* Arriving through the mesh edge, they are trustworthy. The EnvoyFilter
|
|
8
|
+
``ext-authz-enforce.yaml`` strips every client-supplied tenant header and
|
|
9
|
+
re-injects the value ``license-authz`` resolved from the license token, so by
|
|
10
|
+
the time the header reaches the pod it is an assertion by an authority.
|
|
11
|
+
* Arriving pod-to-pod, they are **not** trustworthy. In-cluster callers do not
|
|
12
|
+
traverse the edge EnvoyFilter, so any workload that can open a socket to the
|
|
13
|
+
port can send ``X-Tenant-Id: <victim-tenant>`` and be served that tenant's
|
|
14
|
+
data. The header is accepted *above* license introspection in the precedence
|
|
15
|
+
chain, so a forged header wins over having no license at all.
|
|
16
|
+
|
|
17
|
+
``TRUSTED_CIDRS`` does not address this. That knob governs the *no-header* case
|
|
18
|
+
(:mod:`._trusted_networks`); emptying it does nothing to a request that carries
|
|
19
|
+
a forged header, because such a request never reaches the network check.
|
|
20
|
+
|
|
21
|
+
The knob
|
|
22
|
+
--------
|
|
23
|
+
``TRUST_TENANT_HEADER`` decides whether this process believes those headers.
|
|
24
|
+
|
|
25
|
+
**The default is ``true`` — i.e. today's behaviour, which is the insecure one.**
|
|
26
|
+
That is deliberate. This library is consumed by nine ``coding-*`` services with
|
|
27
|
+
different exposure, and several of them legitimately depend on header trust
|
|
28
|
+
(the gateway forwards ``X-Tenant-Id`` to backends that run with
|
|
29
|
+
``ENABLE_AUTH=false`` and have no other way to learn the tenant). A default of
|
|
30
|
+
``false`` would turn a routine lockfile refresh into a multi-service outage.
|
|
31
|
+
So merging this changes nothing, and the security win lands per service, as
|
|
32
|
+
each one sets ``TRUST_TENANT_HEADER=false`` on its Deployment once it is known
|
|
33
|
+
to have a verifiable alternative (a license token, or the gateway in front).
|
|
34
|
+
|
|
35
|
+
Setting it to ``false`` does **not** downgrade the caller to
|
|
36
|
+
``DEFAULT_TENANT_ID``. The header is ignored as though absent and resolution
|
|
37
|
+
continues down the normal chain — license introspection next. A request with a
|
|
38
|
+
forged header and no valid license gets a 401, which is the point.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
#: Values that turn header trust OFF. Anything else leaves it ON.
|
|
49
|
+
_DISABLE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
50
|
+
|
|
51
|
+
#: Values that explicitly turn header trust ON (accepted without complaint).
|
|
52
|
+
_ENABLE_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
53
|
+
|
|
54
|
+
ENV_VAR = "TRUST_TENANT_HEADER"
|
|
55
|
+
|
|
56
|
+
#: Behaviour when the variable is unset. ``True`` == today's behaviour.
|
|
57
|
+
DEFAULT = True
|
|
58
|
+
|
|
59
|
+
_warned_unrecognised: set[str] = set()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def trust_tenant_header() -> bool:
|
|
63
|
+
"""Return True if client-supplied identity headers may be believed.
|
|
64
|
+
|
|
65
|
+
Read from the environment at **call time**, not import time, so that a test
|
|
66
|
+
can flip it with ``monkeypatch.setenv`` and so the behaviour of a running
|
|
67
|
+
process is never pinned by import order.
|
|
68
|
+
|
|
69
|
+
An unrecognised value keeps the default (trusting). A typo must not silently
|
|
70
|
+
make a service *more* restrictive — that would present as an authentication
|
|
71
|
+
outage with no obvious cause. It is logged at ERROR instead, once per
|
|
72
|
+
distinct bad value.
|
|
73
|
+
"""
|
|
74
|
+
raw = os.getenv(ENV_VAR)
|
|
75
|
+
if raw is None:
|
|
76
|
+
return DEFAULT
|
|
77
|
+
|
|
78
|
+
normalised = raw.strip().lower()
|
|
79
|
+
if not normalised:
|
|
80
|
+
# Set-but-empty is how a Deployment expresses "I did not decide".
|
|
81
|
+
# Treat it as unset rather than as a decision.
|
|
82
|
+
return DEFAULT
|
|
83
|
+
if normalised in _DISABLE_VALUES:
|
|
84
|
+
return False
|
|
85
|
+
if normalised in _ENABLE_VALUES:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
if raw not in _warned_unrecognised:
|
|
89
|
+
_warned_unrecognised.add(raw)
|
|
90
|
+
logger.error(
|
|
91
|
+
"%s=%r is not a recognised boolean; falling back to the default (%s). "
|
|
92
|
+
"Use one of %s to disable header trust.",
|
|
93
|
+
ENV_VAR,
|
|
94
|
+
raw,
|
|
95
|
+
DEFAULT,
|
|
96
|
+
sorted(_DISABLE_VALUES),
|
|
97
|
+
)
|
|
98
|
+
return DEFAULT
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""On-behalf-of vouching: who may assert a tenant that is not their own.
|
|
2
|
+
|
|
3
|
+
A `coding-*` service that calls a sibling has, until now, had no way to say
|
|
4
|
+
*"I am the containers service, and I am acting for customer T"*. Every outbound
|
|
5
|
+
call carries a bare ``X-Tenant-Id`` and nothing else — a census of the fleet
|
|
6
|
+
found **zero** sites forwarding an inbound ``Authorization``. So the only way a
|
|
7
|
+
sibling call could succeed was through header trust, which is precisely the hole
|
|
8
|
+
``TRUST_TENANT_HEADER=false`` exists to close. Flipping the gate without this
|
|
9
|
+
module turns working service-to-service calls into 401s.
|
|
10
|
+
|
|
11
|
+
Handing each service its own license does not solve it either: the tenant comes
|
|
12
|
+
from the introspection body, so every S2S call would silently resolve to the
|
|
13
|
+
*calling service's* tenant. That is a data-scoping bug wearing a fix's clothes.
|
|
14
|
+
|
|
15
|
+
The mechanism
|
|
16
|
+
-------------
|
|
17
|
+
A caller presents its **own** service license in ``Authorization: Bearer`` and
|
|
18
|
+
names the **customer** tenant in a plain ``X-Tenant-Id``. The asserted tenant is
|
|
19
|
+
honoured only when the license introspects to a ``subject_id`` on this
|
|
20
|
+
allowlist. This is the fleet-standard shape — ``kag_graph_service``,
|
|
21
|
+
``graph_adapter_service``, ``kag`` and ``kag_orchestrator`` all run it, keyed on
|
|
22
|
+
the same ``subject_id`` that ``licenses/scripts/provision_service_token.py``
|
|
23
|
+
prints when it mints the credential.
|
|
24
|
+
|
|
25
|
+
Unlike KAG — which copy-pastes the gate into seven services because they share
|
|
26
|
+
no framework layer — every ``coding-*`` service consumes this one SDK, so the
|
|
27
|
+
rule lives here once.
|
|
28
|
+
|
|
29
|
+
Why the decision keys on DISAGREEMENT, not on the header's presence
|
|
30
|
+
-------------------------------------------------------------------
|
|
31
|
+
The obvious rule, "a plain ``X-Tenant-Id`` is honoured iff vouched, else 403",
|
|
32
|
+
would break the entire north-south path. ``coding-gateway`` forwards the
|
|
33
|
+
client's ``Authorization`` verbatim (its ``_HOP_BY_HOP`` list holds
|
|
34
|
+
``proxy-authorization`` but **not** ``authorization``) *and* injects its own
|
|
35
|
+
resolved ``x-tenant-id``. So every browser-originated request already arrives
|
|
36
|
+
carrying both headers — the vouching shape, on the wire today, with nothing
|
|
37
|
+
evaluating it. Under the naive rule the gateway's own injection would be
|
|
38
|
+
indistinguishable from a forgery and every request behind it would 403.
|
|
39
|
+
|
|
40
|
+
The tenant the gateway injects is the one it resolved from that same license, so
|
|
41
|
+
it *agrees* with the token. Refusing only on disagreement keeps that path intact
|
|
42
|
+
while still closing forgery:
|
|
43
|
+
|
|
44
|
+
=========================== ========= ==========================================
|
|
45
|
+
asserted vs token's tenant vouched? outcome
|
|
46
|
+
=========================== ========= ==========================================
|
|
47
|
+
absent -- token's tenant (unchanged behaviour)
|
|
48
|
+
equal -- token's tenant (the gateway's own inject)
|
|
49
|
+
differs yes the **asserted** tenant — the grant
|
|
50
|
+
differs no **403** ``TENANT_ASSERTION_NOT_PERMITTED``
|
|
51
|
+
=========================== ========= ==========================================
|
|
52
|
+
|
|
53
|
+
Fail-closed, and the allowlist IS the rollout lever
|
|
54
|
+
---------------------------------------------------
|
|
55
|
+
Unset or empty vouches for nobody, which makes this module inert on install:
|
|
56
|
+
with no allowlist, a differing assertion can only ever be refused, and today
|
|
57
|
+
every such request is already refused by ``TRUST_TENANT_HEADER=false`` (or
|
|
58
|
+
accepted wholesale when the gate is on, in which case this code is never
|
|
59
|
+
reached). There is deliberately **no permissive spelling** — no wildcard, no
|
|
60
|
+
"allow all", no escape-hatch variable. The fleet's own doctrine, written into
|
|
61
|
+
the karma Deployment: *"an empty list is the fail-safe"*.
|
|
62
|
+
|
|
63
|
+
That also makes this safer than the boolean it complements.
|
|
64
|
+
``TRUST_TENANT_HEADER`` fails **open** on a typo — ``"flase"`` logs an error and
|
|
65
|
+
keeps trusting. A typo in an allowlist entry can only ever fail to match.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
import logging
|
|
69
|
+
import os
|
|
70
|
+
import uuid
|
|
71
|
+
|
|
72
|
+
from starlette.requests import HTTPConnection
|
|
73
|
+
|
|
74
|
+
logger = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
#: Comma-separated ``subject_id``s permitted to assert a tenant that is not the
|
|
77
|
+
#: one their own license names. The value is what
|
|
78
|
+
#: ``licenses/scripts/provision_service_token.py`` prints as ``subject_id``.
|
|
79
|
+
ENV_VAR = "CODING_ONBEHALF_SUBJECTS"
|
|
80
|
+
|
|
81
|
+
#: Marker prefix the middleware synthesises for a license that names no subject
|
|
82
|
+
#: (``license:<tenant_id>``). It is not a subject anyone was issued, so it must
|
|
83
|
+
#: never be allowlistable — see :func:`onbehalf_subjects`.
|
|
84
|
+
_SYNTHETIC_SUBJECT_PREFIX = "license:"
|
|
85
|
+
|
|
86
|
+
_warned_synthetic: set[str] = set()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def onbehalf_subjects() -> frozenset[str]:
|
|
90
|
+
"""Parse the allowlist. Read at call time so tests and rollouts can move it.
|
|
91
|
+
|
|
92
|
+
Entries are stripped and blanks dropped, so trailing commas and wrapped YAML
|
|
93
|
+
are harmless.
|
|
94
|
+
|
|
95
|
+
🔴 A ``license:``-prefixed entry is **rejected**, loudly and every time.
|
|
96
|
+
When an active license carries no ``subject_id`` the middleware substitutes
|
|
97
|
+
the marker ``license:<tenant_id>`` so the blank never reaches the ``sub``
|
|
98
|
+
slot. That marker is shared by *every* subject-less license issued for that
|
|
99
|
+
tenant, so allowlisting one would hand the vouch to all of them at once —
|
|
100
|
+
the allowlist would stop naming a service and start naming a crowd. There is
|
|
101
|
+
no legitimate reason to list one, so this is a hard reject rather than a
|
|
102
|
+
warning: dropping the entry fails closed, honouring it fails open.
|
|
103
|
+
"""
|
|
104
|
+
raw = os.getenv(ENV_VAR, "")
|
|
105
|
+
subjects: set[str] = set()
|
|
106
|
+
|
|
107
|
+
for entry in raw.split(","):
|
|
108
|
+
subject = entry.strip()
|
|
109
|
+
if not subject:
|
|
110
|
+
continue
|
|
111
|
+
if subject.startswith(_SYNTHETIC_SUBJECT_PREFIX):
|
|
112
|
+
if subject not in _warned_synthetic:
|
|
113
|
+
_warned_synthetic.add(subject)
|
|
114
|
+
logger.error(
|
|
115
|
+
"%s contains the synthetic marker %r, which is not a real subject and "
|
|
116
|
+
"is shared by every subject-less license for that tenant. Ignoring it. "
|
|
117
|
+
"Use the subject_id printed by provision_service_token.py.",
|
|
118
|
+
ENV_VAR,
|
|
119
|
+
subject,
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
subjects.add(subject)
|
|
123
|
+
|
|
124
|
+
return frozenset(subjects)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def asserted_tenant(request: HTTPConnection) -> str:
|
|
128
|
+
"""The tenant the caller is asking to act for, or ``""`` if none.
|
|
129
|
+
|
|
130
|
+
Read **ungated**. ``AuthMiddleware`` and ``require_tenant`` both null their
|
|
131
|
+
own ``x-tenant-id`` read when ``trust_tenant_header()`` is false; reusing
|
|
132
|
+
that value here would make vouching dead in exactly the deployment posture it
|
|
133
|
+
exists for.
|
|
134
|
+
|
|
135
|
+
A ``Bearer``-shaped value is treated as absent. Some fleet services overload
|
|
136
|
+
``X-Tenant-Id`` to carry a credential (``coding-ai``'s worker-manager client
|
|
137
|
+
does), and binding a tenant literally named ``"Bearer"`` would be a smuggling
|
|
138
|
+
path. The split-on-whitespace form catches ``"Bearer tok"`` and
|
|
139
|
+
``"Bearer\\ttok"``, which a ``startswith("Bearer ")`` test does not.
|
|
140
|
+
"""
|
|
141
|
+
raw = (request.headers.get("x-tenant-id") or "").strip()
|
|
142
|
+
if not raw:
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
parts = raw.split()
|
|
146
|
+
if parts and parts[0].lower() == "bearer":
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
return raw
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def may_vouch(subject_id: str | None) -> bool:
|
|
153
|
+
"""True iff ``subject_id`` is allowlisted to act for another tenant.
|
|
154
|
+
|
|
155
|
+
Fail-closed at every exit: no subject, empty allowlist, synthetic marker, or
|
|
156
|
+
simply not listed.
|
|
157
|
+
"""
|
|
158
|
+
if not subject_id:
|
|
159
|
+
return False
|
|
160
|
+
if subject_id.startswith(_SYNTHETIC_SUBJECT_PREFIX):
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
subjects = onbehalf_subjects()
|
|
164
|
+
if not subjects:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
return subject_id in subjects
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: Attributes this module writes onto ``request.state`` on EVERY path, so a
|
|
171
|
+
#: reader can distinguish "the resolver ran and did not vouch" from "the
|
|
172
|
+
#: resolver never ran". A reader that can only ever see the absence of an
|
|
173
|
+
#: attribute cannot tell those apart, and a guard satisfied by a reader that
|
|
174
|
+
#: never fires is not a guard.
|
|
175
|
+
STATE_VOUCHED = "tenant_vouched"
|
|
176
|
+
STATE_ASSERTED_TENANT = "asserted_tenant"
|
|
177
|
+
STATE_VOUCHING_SUBJECT = "vouching_subject_id"
|
|
178
|
+
STATE_LICENSE_ID = "license_id"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _record_not_vouched(request: HTTPConnection) -> None:
|
|
182
|
+
"""Mark this request as *not* vouched. Called on both non-vouch exits."""
|
|
183
|
+
request.state.tenant_vouched = False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def tenant_license_verified(request: HTTPConnection) -> bool:
|
|
187
|
+
"""True iff this request's tenant was resolved from a **verified licence**.
|
|
188
|
+
|
|
189
|
+
False when it came from a trusted ``X-Tenant-Id`` header, from the
|
|
190
|
+
trusted-network bypass, or from nowhere at all — none of which reach
|
|
191
|
+
:func:`resolve_tenant_assertion`, which is why its absence is the signal.
|
|
192
|
+
|
|
193
|
+
🔴 This is the question a service must ask before lending its own credential
|
|
194
|
+
to an outbound call. ``coding-gateway`` presents its licence downstream so
|
|
195
|
+
the callee will honour the tenant it injects; if that tenant came from an
|
|
196
|
+
unverified header, the gateway is signing for a claim it never checked, and
|
|
197
|
+
the callee's allowlist — which exists to police exactly this — is satisfied
|
|
198
|
+
by the wrong party.
|
|
199
|
+
|
|
200
|
+
That is not hypothetical. Driven live on 2026-08-14: a forged, credential-less
|
|
201
|
+
``x-tenant-id`` on a WebSocket upgrade to coding-gateway was accepted under
|
|
202
|
+
header trust, the gateway attached its own allowlisted licence, and
|
|
203
|
+
coding-analysis logged ``Vouched on-behalf-of: subject <svc-coding-gateway>
|
|
204
|
+
asserted tenant <someone else's>``. Any pod could read any tenant. The gate
|
|
205
|
+
below is what makes that impossible: with the tenant unverified, no
|
|
206
|
+
credential is lent, and the callee refuses as it should.
|
|
207
|
+
"""
|
|
208
|
+
return getattr(request.state, STATE_VOUCHED, None) is not None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def tenant_was_vouched(request: HTTPConnection) -> bool:
|
|
212
|
+
"""True iff this request's tenant came from an on-behalf-of vouch.
|
|
213
|
+
|
|
214
|
+
The accessor exists so consumers do not hand-spell ``request.state``: a
|
|
215
|
+
renamed attribute then breaks in one place instead of silently reading
|
|
216
|
+
``False`` forever at every call site. ``coding-gateway`` is the consumer —
|
|
217
|
+
it decides whether to forward the caller's credential or substitute its own.
|
|
218
|
+
|
|
219
|
+
**Defaults to False when the resolver never ran**, which is the safe answer:
|
|
220
|
+
no vouch happened, so nothing about the outbound credential should change.
|
|
221
|
+
That covers the header-trust, trusted-network and no-credential paths, none
|
|
222
|
+
of which reach :func:`resolve_tenant_assertion` at all.
|
|
223
|
+
"""
|
|
224
|
+
return bool(getattr(request.state, STATE_VOUCHED, False))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def resolve_tenant_assertion(
|
|
228
|
+
request: HTTPConnection, *, subject_id: str | None, token_tenant: str
|
|
229
|
+
) -> tuple[str, bool]:
|
|
230
|
+
"""Apply the disagreement rule. Returns ``(tenant_to_use, refused)``.
|
|
231
|
+
|
|
232
|
+
The single implementation of the table in this module's docstring. Both
|
|
233
|
+
:class:`~.middleware.AuthMiddleware` and :func:`~.tenant.require_tenant`
|
|
234
|
+
call it rather than each re-deriving the rule — the two have drifted before
|
|
235
|
+
(``TRUSTED_CIDRS`` and ``DEFAULT_TENANT_ID`` were once byte-identical copies,
|
|
236
|
+
and fixing one left the other permissive).
|
|
237
|
+
|
|
238
|
+
When ``refused`` is true the caller must reject with **403**, not 401: the
|
|
239
|
+
request is authenticated — the license is live and verified — it is simply
|
|
240
|
+
not authorised to speak for that tenant. A 401 would invite the caller to
|
|
241
|
+
retry with different credentials, which is not the problem.
|
|
242
|
+
|
|
243
|
+
Tenants are compared case-insensitively. The equal-case exists to recognise
|
|
244
|
+
``coding-gateway`` re-injecting the tenant it resolved from this same token,
|
|
245
|
+
and a casing difference there would produce a false 403 on the entire
|
|
246
|
+
north-south path. It costs nothing: honouring a *differing* tenant still
|
|
247
|
+
requires the allowlist, so this only widens what counts as "the same tenant",
|
|
248
|
+
never who may change it.
|
|
249
|
+
|
|
250
|
+
The outcome is also RECORDED on ``request.state`` (see ``STATE_*`` above),
|
|
251
|
+
because a caller downstream of this decision has to act on it and cannot
|
|
252
|
+
re-derive it. ``coding-gateway`` is that caller: it forwards the client's own
|
|
253
|
+
credential when the tenant agreed, and substitutes its own when it had to
|
|
254
|
+
vouch — because on a vouched call the client's credential names a *different*
|
|
255
|
+
tenant than the one being asserted onward, which is precisely what the next
|
|
256
|
+
hop refuses. Recording it here rather than at each call site is the same
|
|
257
|
+
reason the rule itself lives here: ``AuthMiddleware``, ``require_tenant`` and
|
|
258
|
+
``optional_tenant`` have drifted apart before.
|
|
259
|
+
"""
|
|
260
|
+
asserted = asserted_tenant(request)
|
|
261
|
+
if not asserted or asserted.casefold() == token_tenant.casefold():
|
|
262
|
+
_record_not_vouched(request)
|
|
263
|
+
return token_tenant, False
|
|
264
|
+
|
|
265
|
+
if not may_vouch(subject_id):
|
|
266
|
+
logger.warning(
|
|
267
|
+
"Refusing tenant assertion: subject %r is not in %s and asserted a tenant "
|
|
268
|
+
"other than the one its license names (path=%s)",
|
|
269
|
+
subject_id,
|
|
270
|
+
ENV_VAR,
|
|
271
|
+
request.url.path,
|
|
272
|
+
)
|
|
273
|
+
_record_not_vouched(request)
|
|
274
|
+
return token_tenant, True
|
|
275
|
+
|
|
276
|
+
logger.info(
|
|
277
|
+
"Vouched on-behalf-of: subject %r asserted tenant %r (path=%s)",
|
|
278
|
+
subject_id,
|
|
279
|
+
asserted,
|
|
280
|
+
request.url.path,
|
|
281
|
+
)
|
|
282
|
+
request.state.tenant_vouched = True
|
|
283
|
+
request.state.asserted_tenant = asserted
|
|
284
|
+
# Audit trail, and the attribution a vouched call must carry onward: the
|
|
285
|
+
# CUSTOMER's license (forwarded by the caller), never the vouching service's
|
|
286
|
+
# own — otherwise every customer's spend bills to the service. Set here for
|
|
287
|
+
# all three resolvers; AuthMiddleware used to set these two itself, and
|
|
288
|
+
# require_tenant / optional_tenant did not, so a WebSocket route (which only
|
|
289
|
+
# ever runs require_tenant) had no attribution at all.
|
|
290
|
+
request.state.vouching_subject_id = subject_id
|
|
291
|
+
request.state.license_id = forwarded_license_id(request)
|
|
292
|
+
return asserted, False
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def forwarded_license_id(request: HTTPConnection) -> str | None:
|
|
296
|
+
"""The customer's ``X-License-Id``, or ``None`` if absent/malformed.
|
|
297
|
+
|
|
298
|
+
On a vouched call the license that should be *attributed* is the customer's,
|
|
299
|
+
forwarded by the caller — never the vouching service's own, which would bill
|
|
300
|
+
every customer's spend to the service. Same rule as
|
|
301
|
+
``kag_orchestrator``'s vouched branch.
|
|
302
|
+
|
|
303
|
+
Validated as a UUID and returned as the canonical string form, so a
|
|
304
|
+
caller-controlled header cannot inject an arbitrary value into a field that
|
|
305
|
+
downstream consumers treat as an identifier.
|
|
306
|
+
"""
|
|
307
|
+
raw = (request.headers.get("x-license-id") or "").strip()
|
|
308
|
+
if not raw:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
return str(uuid.UUID(raw))
|
|
313
|
+
except ValueError:
|
|
314
|
+
logger.warning("Ignoring malformed X-License-Id on a vouched request")
|
|
315
|
+
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Trusted-network evaluation for the auth layer.
|
|
2
|
+
|
|
3
|
+
``TRUSTED_CIDRS`` lets an operator nominate source networks whose requests are
|
|
4
|
+
accepted **without any credential** and granted ``DEFAULT_TENANT_ID``. It is an
|
|
5
|
+
authentication *bypass*, so its default is empty: a caller is trusted only when
|
|
6
|
+
someone deliberately says so.
|
|
7
|
+
|
|
8
|
+
Arming it takes two decisions, not one: *which* networks (here) and *which
|
|
9
|
+
tenant* they then act as (``DEFAULT_TENANT_ID``, see :mod:`._default_tenant`,
|
|
10
|
+
which has no default either and refuses to boot if this list is non-empty while
|
|
11
|
+
it is unset).
|
|
12
|
+
|
|
13
|
+
Why the default is empty (changed 2026-07-29)
|
|
14
|
+
---------------------------------------------
|
|
15
|
+
The previous default was
|
|
16
|
+
``127.0.0.1/32,::1/128,192.168.0.0/16,172.16.0.0/12,10.0.0.0/8``. On this fleet
|
|
17
|
+
every pod carries an address in the k3s pod CIDR ``10.42.0.0/16``, which is a
|
|
18
|
+
subset of ``10.0.0.0/8`` — so **every pod in the cluster** satisfied the bypass
|
|
19
|
+
and was handed ``DEFAULT_TENANT_ID`` instead of a 401. That was demonstrated
|
|
20
|
+
live: an unrelated pod in the ``artifacts`` namespace, with no Authorization
|
|
21
|
+
header, no X-Tenant-Id and no license token, read
|
|
22
|
+
``GET /api/containers/`` from coding-containers and got HTTP 200 with data.
|
|
23
|
+
|
|
24
|
+
"The caller's IP looks local" is not authentication in a cluster where every
|
|
25
|
+
workload is local. Legitimate in-mesh callers do not need this: coding-gateway
|
|
26
|
+
strips client-supplied identity headers and re-injects ``x-tenant-id`` from the
|
|
27
|
+
value it resolved itself (see ``coding-gateway`` ``routers/proxy.py``), so
|
|
28
|
+
backends receive tenant context as a header from the gateway, and the mesh
|
|
29
|
+
AuthorizationPolicy is what restricts who may reach the port at all.
|
|
30
|
+
|
|
31
|
+
This module exists so the parsing lives in exactly one place. It previously
|
|
32
|
+
existed as byte-identical copies in ``middleware.py`` and ``tenant.py``; a fix
|
|
33
|
+
applied to one would silently have left the other permissive.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import ipaddress
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_Network = ipaddress.IPv4Network | ipaddress.IPv6Network
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_cidrs(raw: str) -> list[_Network]:
|
|
48
|
+
"""Parse a comma-separated CIDR list, skipping (and logging) bad entries.
|
|
49
|
+
|
|
50
|
+
A malformed entry must not take the whole list down to empty, because an
|
|
51
|
+
empty list is *more* restrictive and would turn a typo into an outage that
|
|
52
|
+
looks like an auth bug.
|
|
53
|
+
"""
|
|
54
|
+
networks: list[_Network] = []
|
|
55
|
+
for chunk in raw.split(","):
|
|
56
|
+
cidr = chunk.strip()
|
|
57
|
+
if not cidr:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
networks.append(ipaddress.ip_network(cidr, strict=False))
|
|
61
|
+
except ValueError:
|
|
62
|
+
logger.error("TRUSTED_CIDRS: ignoring unparseable entry %r", cidr)
|
|
63
|
+
return networks
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_trusted_networks() -> list[_Network]:
|
|
67
|
+
"""Read ``TRUSTED_CIDRS`` from the environment. Empty by default."""
|
|
68
|
+
return _parse_cidrs(os.getenv("TRUSTED_CIDRS", ""))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
TRUSTED_NETWORKS: list[_Network] = load_trusted_networks()
|
|
72
|
+
|
|
73
|
+
if TRUSTED_NETWORKS:
|
|
74
|
+
logger.warning(
|
|
75
|
+
"TRUSTED_CIDRS is set to %s — requests from these networks are accepted "
|
|
76
|
+
"with NO credential and granted DEFAULT_TENANT_ID. This is an "
|
|
77
|
+
"authentication bypass; confirm it is intended.",
|
|
78
|
+
[str(n) for n in TRUSTED_NETWORKS],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_trusted_network(host: str | None) -> bool:
|
|
83
|
+
"""True if *host* is a literal IP inside a configured trusted network.
|
|
84
|
+
|
|
85
|
+
Reads :data:`TRUSTED_NETWORKS` at call time (not a captured copy) so tests
|
|
86
|
+
can monkeypatch the module attribute.
|
|
87
|
+
"""
|
|
88
|
+
if not host:
|
|
89
|
+
return False
|
|
90
|
+
try:
|
|
91
|
+
addr = ipaddress.ip_address(host)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return False
|
|
94
|
+
return any(addr in net for net in TRUSTED_NETWORKS)
|