Skip to content

Commit b166660

Browse files
zeevdrclaude
andcommitted
fix(auth): warn on insecure token and use composite credentials on TLS channels
When insecure=True and a token is provided, emit a UserWarning at client construction time so callers know the token will travel in cleartext. When TLS is active (insecure=False or credentials is set), embed the token via grpc.composite_channel_credentials + metadata_call_credentials so it is protected by the TLS layer rather than sent as a raw authorization header. Document the secure-default expectation in the token and insecure docstring parameters of both ConfigClient and AsyncConfigClient. Co-Authored-By: Claude <noreply@anthropic.com> Closes #53
1 parent 1162962 commit b166660

5 files changed

Lines changed: 210 additions & 17 deletions

File tree

sdk/src/opendecree/_channel.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,69 @@
1414
]
1515

1616

17+
def _token_call_credentials(token: str) -> grpc.CallCredentials:
18+
"""Return gRPC call credentials that inject a Bearer token."""
19+
20+
def _callback(context: object, callback: object) -> None: # type: ignore[type-arg]
21+
assert callable(callback)
22+
callback([("authorization", f"Bearer {token}")], None)
23+
24+
return grpc.metadata_call_credentials(_callback)
25+
26+
1727
def create_channel(
1828
target: str,
1929
*,
2030
insecure: bool = True,
2131
credentials: grpc.ChannelCredentials | None = None,
32+
token: str | None = None,
2233
) -> grpc.Channel:
23-
"""Create a gRPC channel with sensible defaults."""
24-
if credentials is not None:
25-
return grpc.secure_channel(target, credentials, options=_DEFAULT_OPTIONS)
26-
if insecure:
27-
return grpc.insecure_channel(target, options=_DEFAULT_OPTIONS)
28-
return grpc.secure_channel(target, grpc.ssl_channel_credentials(), options=_DEFAULT_OPTIONS)
34+
"""Create a gRPC channel with sensible defaults.
35+
36+
When *token* is provided and TLS is active (``insecure=False`` or
37+
*credentials* is given), the token is embedded via
38+
``composite_channel_credentials`` so it is protected by the TLS layer.
39+
On an insecure channel the token is sent as a raw header — callers should
40+
warn the user before allowing this.
41+
"""
42+
channel_creds: grpc.ChannelCredentials | None = credentials
43+
if channel_creds is None and not insecure:
44+
channel_creds = grpc.ssl_channel_credentials()
45+
46+
if channel_creds is not None:
47+
if token:
48+
channel_creds = grpc.composite_channel_credentials(
49+
channel_creds, _token_call_credentials(token)
50+
)
51+
return grpc.secure_channel(target, channel_creds, options=_DEFAULT_OPTIONS)
52+
53+
return grpc.insecure_channel(target, options=_DEFAULT_OPTIONS)
2954

3055

3156
def create_aio_channel(
3257
target: str,
3358
*,
3459
insecure: bool = True,
3560
credentials: grpc.ChannelCredentials | None = None,
61+
token: str | None = None,
3662
) -> grpc.aio.Channel:
37-
"""Create an async gRPC channel with sensible defaults."""
38-
if credentials is not None:
39-
return grpc.aio.secure_channel(target, credentials, options=_DEFAULT_OPTIONS)
40-
if insecure:
41-
return grpc.aio.insecure_channel(target, options=_DEFAULT_OPTIONS)
42-
return grpc.aio.secure_channel(target, grpc.ssl_channel_credentials(), options=_DEFAULT_OPTIONS)
63+
"""Create an async gRPC channel with sensible defaults.
64+
65+
When *token* is provided and TLS is active (``insecure=False`` or
66+
*credentials* is given), the token is embedded via
67+
``composite_channel_credentials`` so it is protected by the TLS layer.
68+
On an insecure channel the token is sent as a raw header — callers should
69+
warn the user before allowing this.
70+
"""
71+
channel_creds: grpc.ChannelCredentials | None = credentials
72+
if channel_creds is None and not insecure:
73+
channel_creds = grpc.ssl_channel_credentials()
74+
75+
if channel_creds is not None:
76+
if token:
77+
channel_creds = grpc.composite_channel_credentials(
78+
channel_creds, _token_call_credentials(token)
79+
)
80+
return grpc.aio.secure_channel(target, channel_creds, options=_DEFAULT_OPTIONS)
81+
82+
return grpc.aio.insecure_channel(target, options=_DEFAULT_OPTIONS)

sdk/src/opendecree/async_client.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import warnings
1011
from datetime import timedelta
1112
from typing import TYPE_CHECKING, overload
1213

@@ -61,7 +62,12 @@ def __init__(
6162
role: Role for ``x-role`` metadata header. Defaults to ``"superadmin"``.
6263
tenant_id: Default tenant for ``x-tenant-id`` metadata header.
6364
token: Bearer token. When set, metadata headers are not sent.
65+
On a TLS channel the token is embedded via
66+
``composite_channel_credentials`` and protected by the TLS
67+
layer. On an insecure channel it travels in cleartext and a
68+
``UserWarning`` is raised — prefer TLS in production.
6469
insecure: Use plaintext (no TLS). Defaults to True for local dev.
70+
Do not combine with *token* in production.
6571
credentials: TLS channel credentials. Overrides *insecure*.
6672
timeout: Default per-RPC timeout in seconds. Defaults to 10.
6773
retry: Retry configuration. Defaults to ``RetryConfig()``.
@@ -70,12 +76,28 @@ def __init__(
7076
self._timeout = timeout
7177
self._retry = retry if retry is not None else RetryConfig()
7278

79+
tls_active = credentials is not None or not insecure
80+
if token and not tls_active:
81+
warnings.warn(
82+
"Bearer token sent over insecure channel without TLS — "
83+
"the token will be transmitted in cleartext. "
84+
"Set insecure=False or provide credentials in production.",
85+
UserWarning,
86+
stacklevel=2,
87+
)
88+
89+
# On a TLS channel, embed the token via composite_channel_credentials
90+
# (protected by TLS). On an insecure channel, fall back to raw header.
7391
# grpc.aio doesn't support intercept_channel, so we inject metadata
74-
# directly on each call via self._metadata().
92+
# directly on each call via self._auth_metadata.
93+
channel_token = token if tls_active else None
94+
metadata_token = token if not tls_active else None
7595
self._auth_metadata = _build_metadata(
76-
subject=subject, role=role, tenant_id=tenant_id, token=token
96+
subject=subject, role=role, tenant_id=tenant_id, token=metadata_token
97+
)
98+
self._channel = create_aio_channel(
99+
target, insecure=insecure, credentials=credentials, token=channel_token
77100
)
78-
self._channel = create_aio_channel(target, insecure=insecure, credentials=credentials)
79101

80102
cs_pb2, cs_grpc = ensure_stubs()
81103
self._stub = cs_grpc.ConfigServiceStub(self._channel)

sdk/src/opendecree/client.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ def __init__(
6565
role: Role for ``x-role`` metadata header. Defaults to ``"superadmin"``.
6666
tenant_id: Default tenant for ``x-tenant-id`` metadata header.
6767
token: Bearer token. When set, metadata headers are not sent.
68+
On a TLS channel the token is embedded via
69+
``composite_channel_credentials`` and protected by the TLS
70+
layer. On an insecure channel it travels in cleartext and a
71+
``UserWarning`` is raised — prefer TLS in production.
6872
insecure: Use plaintext (no TLS). Defaults to True for local dev.
73+
Do not combine with *token* in production.
6974
credentials: TLS channel credentials. Overrides *insecure*.
7075
timeout: Default per-RPC timeout in seconds. Defaults to 10.
7176
retry: Retry configuration. Defaults to ``RetryConfig()``.
@@ -74,12 +79,30 @@ def __init__(
7479
self._timeout = timeout
7580
self._retry = retry if retry is not None else RetryConfig()
7681

77-
metadata = _build_metadata(subject=subject, role=role, tenant_id=tenant_id, token=token)
82+
tls_active = credentials is not None or not insecure
83+
if token and not tls_active:
84+
warnings.warn(
85+
"Bearer token sent over insecure channel without TLS — "
86+
"the token will be transmitted in cleartext. "
87+
"Set insecure=False or provide credentials in production.",
88+
UserWarning,
89+
stacklevel=2,
90+
)
91+
92+
# On a TLS channel, embed the token via composite_channel_credentials
93+
# (protected by TLS). On an insecure channel, fall back to raw header.
94+
channel_token = token if tls_active else None
95+
metadata_token = token if not tls_active else None
96+
metadata = _build_metadata(
97+
subject=subject, role=role, tenant_id=tenant_id, token=metadata_token
98+
)
7899
interceptors: list[grpc.UnaryUnaryClientInterceptor] = []
79100
if metadata:
80101
interceptors.append(AuthInterceptor(metadata))
81102

82-
channel = create_channel(target, insecure=insecure, credentials=credentials)
103+
channel = create_channel(
104+
target, insecure=insecure, credentials=credentials, token=channel_token
105+
)
83106
if interceptors:
84107
self._channel = grpc.intercept_channel(channel, *interceptors)
85108
else:

sdk/tests/test_channel.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,35 @@ def test_secure_default(self):
3434
mock_sec.assert_called_once()
3535
assert ch is mock_sec.return_value
3636

37+
def test_tls_with_token_uses_composite_credentials(self):
38+
creds = MagicMock(spec=grpc.ChannelCredentials)
39+
composite = MagicMock(spec=grpc.ChannelCredentials)
40+
call_creds = MagicMock(spec=grpc.CallCredentials)
41+
with (
42+
patch("opendecree._channel.grpc.secure_channel") as mock_sec,
43+
patch(
44+
"opendecree._channel.grpc.metadata_call_credentials",
45+
return_value=call_creds,
46+
),
47+
patch(
48+
"opendecree._channel.grpc.composite_channel_credentials",
49+
return_value=composite,
50+
) as mock_comp,
51+
):
52+
mock_sec.return_value = MagicMock()
53+
create_channel("host:443", credentials=creds, token="tok")
54+
mock_comp.assert_called_once_with(creds, call_creds)
55+
args = mock_sec.call_args[0]
56+
assert args[1] is composite
57+
58+
def test_insecure_with_token_does_not_use_composite(self):
59+
with patch("opendecree._channel.grpc.insecure_channel") as mock_insecure:
60+
with patch("opendecree._channel.grpc.composite_channel_credentials") as mock_comp:
61+
mock_insecure.return_value = MagicMock()
62+
create_channel("localhost:9090", insecure=True, token="tok")
63+
mock_insecure.assert_called_once()
64+
mock_comp.assert_not_called()
65+
3766

3867
class TestCreateAioChannel:
3968
def test_insecure(self):
@@ -61,3 +90,32 @@ def test_secure_default(self):
6190
mock_ssl.assert_called_once()
6291
mock_sec.assert_called_once()
6392
assert ch is mock_sec.return_value
93+
94+
def test_tls_with_token_uses_composite_credentials(self):
95+
creds = MagicMock(spec=grpc.ChannelCredentials)
96+
composite = MagicMock(spec=grpc.ChannelCredentials)
97+
call_creds = MagicMock(spec=grpc.CallCredentials)
98+
with (
99+
patch("opendecree._channel.grpc.aio.secure_channel") as mock_sec,
100+
patch(
101+
"opendecree._channel.grpc.metadata_call_credentials",
102+
return_value=call_creds,
103+
),
104+
patch(
105+
"opendecree._channel.grpc.composite_channel_credentials",
106+
return_value=composite,
107+
) as mock_comp,
108+
):
109+
mock_sec.return_value = MagicMock()
110+
create_aio_channel("host:443", credentials=creds, token="tok")
111+
mock_comp.assert_called_once_with(creds, call_creds)
112+
args = mock_sec.call_args[0]
113+
assert args[1] is composite
114+
115+
def test_insecure_with_token_does_not_use_composite(self):
116+
with patch("opendecree._channel.grpc.aio.insecure_channel") as mock_insecure:
117+
with patch("opendecree._channel.grpc.composite_channel_credentials") as mock_comp:
118+
mock_insecure.return_value = MagicMock()
119+
create_aio_channel("localhost:9090", insecure=True, token="tok")
120+
mock_insecure.assert_called_once()
121+
mock_comp.assert_not_called()

sdk/tests/test_client.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the sync ConfigClient."""
22

3+
import warnings
34
from unittest.mock import MagicMock, patch
45

56
import grpc
@@ -11,6 +12,55 @@
1112
from tests.conftest import FakeRpcError
1213

1314

15+
def _make_patched_client(**kwargs):
16+
"""Create a ConfigClient with mocked channel and interceptors."""
17+
with patch("opendecree.client.create_channel") as mock_ch:
18+
mock_channel = MagicMock()
19+
mock_ch.return_value = mock_channel
20+
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
21+
mock_intercept.return_value = mock_channel
22+
return opendecree.ConfigClient("localhost:9090", **kwargs)
23+
24+
25+
class TestConfigClientTokenWarning:
26+
def test_warns_on_insecure_with_token(self):
27+
with pytest.warns(UserWarning, match="insecure channel"):
28+
_make_patched_client(token="secret", insecure=True)
29+
30+
def test_no_warning_on_tls_with_token(self):
31+
creds = MagicMock(spec=grpc.ChannelCredentials)
32+
with warnings.catch_warnings():
33+
warnings.simplefilter("error", UserWarning)
34+
_make_patched_client(token="secret", credentials=creds)
35+
36+
def test_no_warning_on_insecure_without_token(self):
37+
with warnings.catch_warnings():
38+
warnings.simplefilter("error", UserWarning)
39+
_make_patched_client(insecure=True)
40+
41+
def test_tls_token_not_in_metadata(self):
42+
creds = MagicMock(spec=grpc.ChannelCredentials)
43+
with patch("opendecree.client.create_channel") as mock_ch:
44+
mock_ch.return_value = MagicMock()
45+
with patch("opendecree.client.grpc.intercept_channel"):
46+
client = opendecree.ConfigClient(
47+
"localhost:9090", token="secret", credentials=creds
48+
)
49+
# No interceptor metadata should contain the raw authorization header.
50+
assert client._raw_channel is mock_ch.return_value
51+
# Token routed to channel factory, not raw header interceptor.
52+
assert mock_ch.call_args.kwargs.get("token") == "secret"
53+
54+
def test_insecure_token_in_metadata_not_channel(self):
55+
with pytest.warns(UserWarning):
56+
with patch("opendecree.client.create_channel") as mock_ch:
57+
mock_ch.return_value = MagicMock()
58+
with patch("opendecree.client.grpc.intercept_channel"):
59+
opendecree.ConfigClient("localhost:9090", token="secret", insecure=True)
60+
# Token should NOT be forwarded to channel factory on insecure path.
61+
assert mock_ch.call_args.kwargs.get("token") is None
62+
63+
1464
class TestConfigClientImport:
1565
"""Test that the client is importable and has the expected API."""
1666

0 commit comments

Comments
 (0)