Skip to content

Commit b57833c

Browse files
authored
Merge pull request #332 from digital-asset/python-2.0-rights
python: Add support for fetching rights from a Daml 2.0 ledger.
2 parents 88f9e68 + 02c7d8b commit b57833c

File tree

14 files changed

+490
-104
lines changed

14 files changed

+490
-104
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ python/dazl.egg-info
1818
python/dist
1919
python/pip-wheel-metadata
2020
app.log
21+
canton.log
22+
canton_errors.log
2123
navigator.log
2224
sandbox.log
2325
target

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
7.6.5
1+
7.7.0

_build/daml-connect/daml-connect.conf

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[tool.poetry]
55
name = "dazl"
6-
version = "7.6.5"
6+
version = "7.7.0"
77
description = "high-level Ledger API client for Daml ledgers"
88
license = "Apache-2.0"
99
authors = ["Davin K. Tanabe <davin.tanabe@digitalasset.com>"]

python/dazl/ledger/api_types.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright (c) 2017-2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
3+
import abc
34
from datetime import datetime
5+
import sys
46
from typing import (
57
TYPE_CHECKING,
68
AbstractSet,
@@ -19,7 +21,15 @@
1921
from ..prim import LEDGER_STRING_REGEX, ContractData, ContractId, Party, to_parties
2022
from ..util.typing import safe_cast
2123

24+
if sys.version_info >= (3, 8):
25+
from typing import final
26+
else:
27+
from typing_extensions import final
28+
29+
2230
__all__ = [
31+
"ActAs",
32+
"Admin",
2333
"ApplicationMeteringReport",
2434
"ArchiveEvent",
2535
"Boundary",
@@ -35,6 +45,8 @@
3545
"ExerciseResponse",
3646
"ParticipantMeteringReport",
3747
"PartyInfo",
48+
"ReadAs",
49+
"Right",
3850
"SubmitResponse",
3951
"User",
4052
]
@@ -603,6 +615,54 @@ def __init__(self, id: str, primary_party: Party):
603615
self.primary_party = primary_party
604616

605617

618+
class Right(abc.ABC):
619+
def __setattr__(self, key, value):
620+
"""
621+
Overridden to make Right objects read-only.
622+
"""
623+
raise AttributeError
624+
625+
626+
@final
627+
class ReadAs(Right):
628+
__slots__ = ("party",)
629+
__match_args__ = ("party",)
630+
631+
party: Party
632+
633+
def __init__(self, __party: Party):
634+
object.__setattr__(self, "party", __party)
635+
636+
def __repr__(self) -> str:
637+
return f"ReadAs({self.party!r})"
638+
639+
640+
@final
641+
class ActAs(Right):
642+
__slots__ = ("party",)
643+
__match_args__ = ("party",)
644+
645+
party: Party
646+
647+
def __init__(self, __party: Party):
648+
object.__setattr__(self, "party", __party)
649+
650+
def __repr__(self) -> str:
651+
return f"ActAs({self.party!r})"
652+
653+
654+
@final
655+
class _Admin(Right):
656+
__slots__ = ()
657+
__match_args__ = ()
658+
659+
def __repr__(self) -> str:
660+
return "Admin"
661+
662+
663+
Admin = _Admin()
664+
665+
606666
class PartyInfo:
607667
"""
608668
Full information about a ``Party``.

python/dazl/ledger/config/access.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Optional,
2020
Union,
2121
)
22+
import warnings
2223

2324
from ...prim import Party
2425
from .exc import ConfigError
@@ -189,7 +190,7 @@ def token(self) -> str:
189190
raise NotImplementedError
190191

191192
@property
192-
def token_version(self) -> "Optional[Literal[1]]":
193+
def token_version(self) -> "Optional[Literal[1, 2]]":
193194
"""
194195
The version of the token supplied at configuration time, as provided by a signing authority
195196
that is trusted by the server.
@@ -210,6 +211,8 @@ class TokenBasedAccessConfig(AccessConfig):
210211
party rights, the application name, and ledger ID are all derived off of the token.
211212
"""
212213

214+
_token_version: Literal[1, 2]
215+
213216
def __init__(self, oauth_token: str):
214217
"""
215218
Initialize a token-based access configuration.
@@ -229,17 +232,34 @@ def token(self) -> str:
229232
@token.setter
230233
def token(self, value: str) -> None:
231234
self._token = value
232-
claims = decode_token(self._token)
235+
claims = decode_token_claims(self._token)
236+
237+
v1_claims = claims.get(DamlLedgerApiNamespace)
238+
if v1_claims is not None:
239+
self._set(
240+
read_as=frozenset(claims.get("readAs", ())),
241+
act_as=frozenset(claims.get("actAs", ())),
242+
admin=bool(claims.get("admin", False)),
243+
)
244+
self._ledger_id = v1_claims.get("ledgerId", None)
245+
self._application_name = v1_claims.get("applicationId", None)
246+
self._token_version = 1
247+
else:
248+
self._token_version = 2
233249

234-
read_as = frozenset(claims.get("readAs", ()))
235-
act_as = frozenset(claims.get("actAs", ()))
250+
def _set(self, *, read_as: Collection[Party], act_as: Collection[Party], admin: bool):
251+
"""
252+
Set the values of this :class:`TokenBasedAccessConfig`.
253+
254+
This is not a public API, and subject to change at any time.
255+
"""
256+
read_as = frozenset(read_as)
257+
act_as = frozenset(act_as)
236258

237259
self._act_as = act_as
238260
self._read_only_as = read_as - act_as
239261
self._read_as = read_as.union(act_as)
240-
self._admin = bool(claims.get("admin", False))
241-
self._ledger_id = claims.get("ledgerId", None)
242-
self._application_name = claims.get("applicationId", None)
262+
self._admin = admin
243263

244264
@property
245265
def read_as(self) -> AbstractSet[Party]:
@@ -266,8 +286,8 @@ def application_name(self) -> str:
266286
return self._application_name
267287

268288
@property
269-
def token_version(self) -> "Literal[1]":
270-
return 1
289+
def token_version(self) -> "Literal[1, 2]":
290+
return self._token_version
271291

272292

273293
class TokenFileBasedAccessConfig(TokenBasedAccessConfig):
@@ -412,17 +432,27 @@ def parties(p: Union[None, Party, Collection[Party]]) -> Collection[Party]:
412432

413433

414434
def decode_token(token: str) -> Mapping[str, Any]:
435+
warnings.warn("decode_token is deprecated; use decode_token_claims instead", DeprecationWarning)
436+
claims = decode_token_claims(token)
437+
claims_dict = claims.get(DamlLedgerApiNamespace)
438+
if claims_dict is None:
439+
raise ValueError(f"JWT is missing claim namespace: {DamlLedgerApiNamespace!r}")
440+
return claims_dict
441+
442+
443+
def decode_token_claims(token: str) -> "Mapping[str, Any]":
444+
"""
445+
Decode the claims section from a JSON Web Token (JWT).
446+
447+
Note that the signature is NOT verified; this is the responsibility of the caller!
448+
"""
415449
components = token.split(".", 3)
416450
if len(components) != 3:
417451
raise ValueError("not a JWT")
418452

419453
pad_bytes = "=" * (-len(components[1]) % 4)
420454
claim_str = base64.urlsafe_b64decode(components[1] + pad_bytes)
421-
claims = json.loads(claim_str)
422-
claims_dict = claims.get(DamlLedgerApiNamespace)
423-
if claims_dict is None:
424-
raise ValueError(f"JWT is missing claim namespace: {DamlLedgerApiNamespace!r}")
425-
return claims_dict
455+
return json.loads(claim_str)
426456

427457

428458
def encode_unsigned_token(

python/dazl/ledger/grpc/channel.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) 2017-2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from typing import List, Tuple, Union, cast
4+
from typing import Any, AsyncIterable, Callable, Iterable, List, Tuple, TypeVar, Union, cast
55
from urllib.parse import urlparse
66

77
from grpc import (
@@ -13,12 +13,29 @@
1313
metadata_call_credentials,
1414
ssl_channel_credentials,
1515
)
16-
from grpc.aio import Channel, insecure_channel, secure_channel
16+
from grpc.aio import (
17+
Channel,
18+
ClientCallDetails,
19+
StreamStreamCall,
20+
StreamStreamClientInterceptor,
21+
StreamUnaryCall,
22+
StreamUnaryClientInterceptor,
23+
UnaryStreamCall,
24+
UnaryStreamClientInterceptor,
25+
UnaryUnaryCall,
26+
UnaryUnaryClientInterceptor,
27+
insecure_channel,
28+
secure_channel,
29+
)
1730

1831
from ..config import Config
1932

2033
__all__ = ["create_channel"]
2134

35+
RequestType = TypeVar("RequestType")
36+
RequestIterableType = Union[Iterable[Any], AsyncIterable[Any]]
37+
ResponseIterableType = AsyncIterable[Any]
38+
2239

2340
def create_channel(config: "Config") -> "Channel":
2441
"""
@@ -55,7 +72,15 @@ def create_channel(config: "Config") -> "Channel":
5572
),
5673
)
5774
return secure_channel(u.netloc, credentials, tuple(options))
75+
76+
elif config.access.token_version is not None:
77+
# Python/C++ libraries refuse to allow "credentials" objects to be passed around on
78+
# non-TLS channels, but they don't check interceptors; use an interceptor to inject
79+
# an Authorization header instead
80+
return insecure_channel(u.netloc, options, interceptors=[GrpcAuthInterceptor(config)])
81+
5882
else:
83+
# no TLS, no tokens--simply create an insecure channel with no adornments
5984
return insecure_channel(u.netloc, options)
6085

6186

@@ -74,3 +99,68 @@ def __call__(self, context: "AuthMetadataContext", callback: "AuthMetadataPlugin
7499
options.append(("authorization", "Bearer " + self._config.access.token))
75100

76101
callback(tuple(options), None)
102+
103+
104+
class GrpcAuthInterceptor(
105+
UnaryUnaryClientInterceptor,
106+
UnaryStreamClientInterceptor,
107+
StreamUnaryClientInterceptor,
108+
StreamStreamClientInterceptor,
109+
):
110+
"""
111+
An interceptor that injects "Authorization" metadata into a request.
112+
113+
This works around the fact that the C++ gRPC libraries (which Python is built on) highly
114+
discourage sending authorization data over the wire unless the connection is protected with TLS.
115+
"""
116+
117+
# NOTE: There are a number of typing errors in the grpc.aio classes, so we're ignoring a handful
118+
# of lines until those problems are addressed.
119+
120+
def __init__(self, config: "Config"):
121+
self._config = config
122+
123+
async def intercept_unary_unary(
124+
self,
125+
continuation: "Callable[[ClientCallDetails, RequestType], UnaryUnaryCall]",
126+
client_call_details: ClientCallDetails,
127+
request: RequestType,
128+
) -> "Union[UnaryUnaryCall, RequestType]":
129+
return await continuation(self._modify_client_call_details(client_call_details), request)
130+
131+
async def intercept_unary_stream(
132+
self,
133+
continuation: "Callable[[ClientCallDetails, RequestType], UnaryStreamCall]",
134+
client_call_details: ClientCallDetails,
135+
request: RequestType,
136+
) -> "Union[ResponseIterableType, UnaryStreamCall]":
137+
return await continuation(self._modify_client_call_details(client_call_details), request)
138+
139+
async def intercept_stream_unary(
140+
self,
141+
continuation: "Callable[[ClientCallDetails, RequestType], StreamUnaryCall]",
142+
client_call_details: ClientCallDetails,
143+
request_iterator: RequestIterableType,
144+
) -> StreamUnaryCall:
145+
return await continuation(
146+
self._modify_client_call_details(client_call_details), request_iterator # type: ignore
147+
)
148+
149+
async def intercept_stream_stream(
150+
self,
151+
continuation: Callable[[ClientCallDetails, RequestType], StreamStreamCall],
152+
client_call_details: ClientCallDetails,
153+
request_iterator: RequestIterableType,
154+
) -> "Union[ResponseIterableType, StreamStreamCall]":
155+
return await continuation(
156+
self._modify_client_call_details(client_call_details), request_iterator # type: ignore
157+
)
158+
159+
def _modify_client_call_details(self, client_call_details: ClientCallDetails):
160+
if (
161+
"authorization" not in client_call_details.metadata
162+
and self._config.access.token_version is not None
163+
):
164+
client_call_details.metadata.add("authorization", f"Bearer {self._config.access.token}")
165+
166+
return client_call_details

0 commit comments

Comments
 (0)