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