otdf-python 0.4.1__py3-none-any.whl → 0.4.3__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.
- otdf_python/autoconfigure_utils.py +0 -2
- otdf_python/cli.py +50 -21
- otdf_python/collection_store.py +0 -1
- otdf_python/ecdh.py +0 -6
- otdf_python/kas_allowlist.py +182 -0
- otdf_python/kas_client.py +44 -2
- otdf_python/kas_connect_rpc_client.py +59 -19
- otdf_python/nanotdf.py +4 -14
- otdf_python/nanotdf_ecdsa_struct.py +0 -2
- otdf_python/nanotdf_type.py +1 -1
- otdf_python/sdk.py +31 -15
- otdf_python/sdk_builder.py +88 -8
- otdf_python/tdf.py +2 -2
- {otdf_python-0.4.1.dist-info → otdf_python-0.4.3.dist-info}/METADATA +3 -2
- {otdf_python-0.4.1.dist-info → otdf_python-0.4.3.dist-info}/RECORD +46 -36
- otdf_python_proto/__init__.py +2 -6
- otdf_python_proto/authorization/__init__.py +10 -0
- otdf_python_proto/authorization/authorization_connect.py +250 -0
- otdf_python_proto/authorization/v2/authorization_connect.py +315 -0
- otdf_python_proto/entityresolution/__init__.py +10 -0
- otdf_python_proto/entityresolution/entity_resolution_connect.py +185 -0
- otdf_python_proto/entityresolution/v2/entity_resolution_connect.py +185 -0
- otdf_python_proto/kas/__init__.py +2 -2
- otdf_python_proto/kas/kas_connect.py +259 -0
- otdf_python_proto/policy/actions/__init__.py +11 -0
- otdf_python_proto/policy/actions/actions_connect.py +380 -0
- otdf_python_proto/policy/attributes/__init__.py +11 -0
- otdf_python_proto/policy/attributes/attributes_connect.py +1310 -0
- otdf_python_proto/policy/kasregistry/__init__.py +11 -0
- otdf_python_proto/policy/kasregistry/key_access_server_registry_connect.py +912 -0
- otdf_python_proto/policy/keymanagement/__init__.py +11 -0
- otdf_python_proto/policy/keymanagement/key_management_connect.py +380 -0
- otdf_python_proto/policy/namespaces/__init__.py +11 -0
- otdf_python_proto/policy/namespaces/namespaces_connect.py +648 -0
- otdf_python_proto/policy/registeredresources/__init__.py +11 -0
- otdf_python_proto/policy/registeredresources/registered_resources_connect.py +770 -0
- otdf_python_proto/policy/resourcemapping/__init__.py +11 -0
- otdf_python_proto/policy/resourcemapping/resource_mapping_connect.py +790 -0
- otdf_python_proto/policy/subjectmapping/__init__.py +11 -0
- otdf_python_proto/policy/subjectmapping/subject_mapping_connect.py +851 -0
- otdf_python_proto/policy/unsafe/__init__.py +11 -0
- otdf_python_proto/policy/unsafe/unsafe_connect.py +705 -0
- otdf_python_proto/wellknownconfiguration/__init__.py +10 -0
- otdf_python_proto/wellknownconfiguration/wellknown_configuration_connect.py +124 -0
- otdf_python_proto/authorization/authorization_pb2_connect.py +0 -191
- otdf_python_proto/authorization/v2/authorization_pb2_connect.py +0 -233
- otdf_python_proto/entityresolution/entity_resolution_pb2_connect.py +0 -149
- otdf_python_proto/entityresolution/v2/entity_resolution_pb2_connect.py +0 -149
- otdf_python_proto/kas/kas_pb2_connect.py +0 -192
- otdf_python_proto/policy/actions/actions_pb2_connect.py +0 -275
- otdf_python_proto/policy/attributes/attributes_pb2_connect.py +0 -863
- otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2_connect.py +0 -611
- otdf_python_proto/policy/keymanagement/key_management_pb2_connect.py +0 -275
- otdf_python_proto/policy/namespaces/namespaces_pb2_connect.py +0 -443
- otdf_python_proto/policy/registeredresources/registered_resources_pb2_connect.py +0 -527
- otdf_python_proto/policy/resourcemapping/resource_mapping_pb2_connect.py +0 -527
- otdf_python_proto/policy/subjectmapping/subject_mapping_pb2_connect.py +0 -569
- otdf_python_proto/policy/unsafe/unsafe_pb2_connect.py +0 -485
- otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2_connect.py +0 -107
- {otdf_python-0.4.1.dist-info → otdf_python-0.4.3.dist-info}/WHEEL +0 -0
- {otdf_python-0.4.1.dist-info → otdf_python-0.4.3.dist-info}/licenses/LICENSE +0 -0
otdf_python/cli.py
CHANGED
|
@@ -112,33 +112,14 @@ def load_client_credentials(creds_file_path: str) -> tuple[str, str]:
|
|
|
112
112
|
) from e
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
def
|
|
116
|
-
"""
|
|
117
|
-
builder = SDKBuilder()
|
|
118
|
-
|
|
119
|
-
if args.platform_url:
|
|
120
|
-
builder.set_platform_endpoint(args.platform_url)
|
|
121
|
-
|
|
122
|
-
# Auto-detect HTTP URLs and enable plaintext mode
|
|
123
|
-
if args.platform_url.startswith("http://") and (
|
|
124
|
-
not hasattr(args, "plaintext") or not args.plaintext
|
|
125
|
-
):
|
|
126
|
-
logger.debug(
|
|
127
|
-
f"Auto-detected HTTP URL {args.platform_url}, enabling plaintext mode"
|
|
128
|
-
)
|
|
129
|
-
builder.use_insecure_plaintext_connection(True)
|
|
130
|
-
|
|
131
|
-
if args.oidc_endpoint:
|
|
132
|
-
builder.set_issuer_endpoint(args.oidc_endpoint)
|
|
133
|
-
|
|
115
|
+
def _configure_auth(builder: SDKBuilder, args) -> None:
|
|
116
|
+
"""Configure authentication on the SDK builder."""
|
|
134
117
|
if args.client_id and args.client_secret:
|
|
135
118
|
builder.client_secret(args.client_id, args.client_secret)
|
|
136
119
|
elif hasattr(args, "with_client_creds_file") and args.with_client_creds_file:
|
|
137
|
-
# Load credentials from file
|
|
138
120
|
client_id, client_secret = load_client_credentials(args.with_client_creds_file)
|
|
139
121
|
builder.client_secret(client_id, client_secret)
|
|
140
122
|
elif hasattr(args, "auth") and args.auth:
|
|
141
|
-
# Parse combined auth string (clientId:clientSecret) - legacy support
|
|
142
123
|
auth_parts = args.auth.split(":")
|
|
143
124
|
if len(auth_parts) != 2:
|
|
144
125
|
raise CLIError(
|
|
@@ -152,12 +133,49 @@ def build_sdk(args) -> SDK:
|
|
|
152
133
|
"Authentication required: provide --with-client-creds-file OR --client-id and --client-secret",
|
|
153
134
|
)
|
|
154
135
|
|
|
136
|
+
|
|
137
|
+
def _configure_kas_allowlist(builder: SDKBuilder, args) -> None:
|
|
138
|
+
"""Configure KAS allowlist on the SDK builder."""
|
|
139
|
+
if hasattr(args, "ignore_kas_allowlist") and args.ignore_kas_allowlist:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"KAS allowlist validation is disabled. This may leak credentials "
|
|
142
|
+
"to malicious servers if decrypting untrusted TDF files."
|
|
143
|
+
)
|
|
144
|
+
builder.with_ignore_kas_allowlist(True)
|
|
145
|
+
elif hasattr(args, "kas_allowlist") and args.kas_allowlist:
|
|
146
|
+
kas_urls = [url.strip() for url in args.kas_allowlist.split(",") if url.strip()]
|
|
147
|
+
logger.debug(f"Using KAS allowlist: {kas_urls}")
|
|
148
|
+
builder.with_kas_allowlist(kas_urls)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_sdk(args) -> SDK:
|
|
152
|
+
"""Build SDK instance from CLI arguments."""
|
|
153
|
+
builder = SDKBuilder()
|
|
154
|
+
|
|
155
|
+
if args.platform_url:
|
|
156
|
+
builder.set_platform_endpoint(args.platform_url)
|
|
157
|
+
# Auto-detect HTTP URLs and enable plaintext mode
|
|
158
|
+
if args.platform_url.startswith("http://") and (
|
|
159
|
+
not hasattr(args, "plaintext") or not args.plaintext
|
|
160
|
+
):
|
|
161
|
+
logger.debug(
|
|
162
|
+
f"Auto-detected HTTP URL {args.platform_url}, enabling plaintext mode"
|
|
163
|
+
)
|
|
164
|
+
builder.use_insecure_plaintext_connection(True)
|
|
165
|
+
|
|
166
|
+
if args.oidc_endpoint:
|
|
167
|
+
builder.set_issuer_endpoint(args.oidc_endpoint)
|
|
168
|
+
|
|
169
|
+
_configure_auth(builder, args)
|
|
170
|
+
|
|
155
171
|
if hasattr(args, "plaintext") and args.plaintext:
|
|
156
172
|
builder.use_insecure_plaintext_connection(True)
|
|
157
173
|
|
|
158
174
|
if args.insecure:
|
|
159
175
|
builder.use_insecure_skip_verify(True)
|
|
160
176
|
|
|
177
|
+
_configure_kas_allowlist(builder, args)
|
|
178
|
+
|
|
161
179
|
return builder.build()
|
|
162
180
|
|
|
163
181
|
|
|
@@ -476,6 +494,17 @@ Where creds.json contains:
|
|
|
476
494
|
security_group.add_argument(
|
|
477
495
|
"--insecure", action="store_true", help="Skip TLS verification"
|
|
478
496
|
)
|
|
497
|
+
security_group.add_argument(
|
|
498
|
+
"--kas-allowlist",
|
|
499
|
+
help="Comma-separated list of trusted KAS URLs. "
|
|
500
|
+
"By default, only the platform URL's KAS endpoint is trusted.",
|
|
501
|
+
)
|
|
502
|
+
security_group.add_argument(
|
|
503
|
+
"--ignore-kas-allowlist",
|
|
504
|
+
action="store_true",
|
|
505
|
+
help="WARNING: Disable KAS allowlist validation. This is insecure and "
|
|
506
|
+
"should only be used for testing. May leak credentials to malicious servers.",
|
|
507
|
+
)
|
|
479
508
|
|
|
480
509
|
# Subcommands
|
|
481
510
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
otdf_python/collection_store.py
CHANGED
otdf_python/ecdh.py
CHANGED
|
@@ -35,20 +35,14 @@ NANOTDF_HKDF_SALT = bytes.fromhex(
|
|
|
35
35
|
class ECDHError(Exception):
|
|
36
36
|
"""Base exception for ECDH operations."""
|
|
37
37
|
|
|
38
|
-
pass
|
|
39
|
-
|
|
40
38
|
|
|
41
39
|
class UnsupportedCurveError(ECDHError):
|
|
42
40
|
"""Raised when an unsupported curve is specified."""
|
|
43
41
|
|
|
44
|
-
pass
|
|
45
|
-
|
|
46
42
|
|
|
47
43
|
class InvalidKeyError(ECDHError):
|
|
48
44
|
"""Raised when a key is invalid or malformed."""
|
|
49
45
|
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
46
|
|
|
53
47
|
def get_curve(curve_name: str) -> ec.EllipticCurve:
|
|
54
48
|
"""Get the cryptography curve object for a given curve name.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""KAS Allowlist: Validates KAS URLs against a list of trusted hosts.
|
|
2
|
+
|
|
3
|
+
This module provides protection against SSRF attacks where malicious TDF files
|
|
4
|
+
could contain attacker-controlled KAS URLs to steal OIDC credentials.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KASAllowlist:
|
|
12
|
+
"""Validates KAS URLs against an allowlist of trusted hosts.
|
|
13
|
+
|
|
14
|
+
This class prevents credential theft by ensuring the SDK only sends
|
|
15
|
+
authentication tokens to trusted KAS endpoints.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
allowlist = KASAllowlist(["https://kas.example.com"])
|
|
19
|
+
allowlist.is_allowed("https://kas.example.com/kas") # True
|
|
20
|
+
allowlist.is_allowed("https://evil.com/kas") # False
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, allowed_urls: list[str] | None = None, allow_all: bool = False):
|
|
25
|
+
"""Initialize the KAS allowlist.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
allowed_urls: List of trusted KAS URLs. Each URL is normalized to
|
|
29
|
+
its origin (scheme://host:port) for comparison.
|
|
30
|
+
allow_all: If True, all URLs are allowed. Use only for testing.
|
|
31
|
+
A warning is logged when this is enabled.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
self._allowed_origins: set[str] = set()
|
|
35
|
+
self._allow_all = allow_all
|
|
36
|
+
|
|
37
|
+
if allow_all:
|
|
38
|
+
logging.warning(
|
|
39
|
+
"KAS allowlist is disabled (allow_all=True). "
|
|
40
|
+
"This is insecure and should only be used for testing."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if allowed_urls:
|
|
44
|
+
for url in allowed_urls:
|
|
45
|
+
self.add(url)
|
|
46
|
+
|
|
47
|
+
def add(self, url: str) -> None:
|
|
48
|
+
"""Add a URL to the allowlist.
|
|
49
|
+
|
|
50
|
+
The URL is normalized to its origin (scheme://host:port) before storage.
|
|
51
|
+
Paths and query strings are stripped.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
url: The KAS URL to allow. Can include path components which
|
|
55
|
+
will be stripped for origin comparison.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
origin = self._get_origin(url)
|
|
59
|
+
self._allowed_origins.add(origin)
|
|
60
|
+
logging.debug(f"Added KAS origin to allowlist: {origin}")
|
|
61
|
+
|
|
62
|
+
def is_allowed(self, url: str) -> bool:
|
|
63
|
+
"""Check if a URL is allowed by the allowlist.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
url: The KAS URL to check.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if the URL's origin is in the allowlist or allow_all is True.
|
|
70
|
+
False otherwise.
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
if self._allow_all:
|
|
74
|
+
logging.debug(f"KAS URL allowed (allow_all=True): {url}")
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
if not self._allowed_origins:
|
|
78
|
+
logging.debug(f"KAS URL rejected (empty allowlist): {url}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
origin = self._get_origin(url)
|
|
82
|
+
allowed = origin in self._allowed_origins
|
|
83
|
+
if allowed:
|
|
84
|
+
logging.debug(f"KAS URL allowed: {url} (origin: {origin})")
|
|
85
|
+
else:
|
|
86
|
+
logging.debug(
|
|
87
|
+
f"KAS URL rejected: {url} (origin: {origin}, "
|
|
88
|
+
f"allowed: {self._allowed_origins})"
|
|
89
|
+
)
|
|
90
|
+
return allowed
|
|
91
|
+
|
|
92
|
+
def validate(self, url: str) -> None:
|
|
93
|
+
"""Validate a URL against the allowlist, raising an exception if not allowed.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
url: The KAS URL to validate.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
SDK.KasAllowlistException: If the URL is not in the allowlist.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
if not self.is_allowed(url):
|
|
103
|
+
# Import here to avoid circular imports
|
|
104
|
+
from .sdk import SDK
|
|
105
|
+
|
|
106
|
+
raise SDK.KasAllowlistException(url, self._allowed_origins)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def allowed_origins(self) -> set[str]:
|
|
110
|
+
"""Return the set of allowed origins (read-only copy)."""
|
|
111
|
+
return self._allowed_origins.copy()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def allow_all(self) -> bool:
|
|
115
|
+
"""Return whether all URLs are allowed."""
|
|
116
|
+
return self._allow_all
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _get_origin(url: str) -> str:
|
|
120
|
+
"""Extract the origin (scheme://host:port) from a URL.
|
|
121
|
+
|
|
122
|
+
This normalizes URLs for comparison by stripping paths and query strings.
|
|
123
|
+
Default ports (80 for http, 443 for https) are included explicitly.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
url: The URL to extract the origin from.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Normalized origin string in format scheme://host:port
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
# Add scheme if missing
|
|
133
|
+
if "://" not in url:
|
|
134
|
+
url = "https://" + url
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
parsed = urlparse(url)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logging.warning(f"Failed to parse URL {url}: {e}")
|
|
140
|
+
# Return the URL as-is if parsing fails
|
|
141
|
+
return url.lower()
|
|
142
|
+
|
|
143
|
+
scheme = (parsed.scheme or "https").lower()
|
|
144
|
+
hostname = (parsed.hostname or "").lower()
|
|
145
|
+
|
|
146
|
+
if not hostname:
|
|
147
|
+
# URL might be malformed, return as-is
|
|
148
|
+
logging.warning(f"Could not extract hostname from URL: {url}")
|
|
149
|
+
return url.lower()
|
|
150
|
+
|
|
151
|
+
# Determine port (use explicit port or default for scheme)
|
|
152
|
+
if parsed.port:
|
|
153
|
+
port = parsed.port
|
|
154
|
+
elif scheme == "http":
|
|
155
|
+
port = 80
|
|
156
|
+
else:
|
|
157
|
+
port = 443
|
|
158
|
+
|
|
159
|
+
return f"{scheme}://{hostname}:{port}"
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_platform_url(cls, platform_url: str) -> "KASAllowlist":
|
|
163
|
+
"""Create an allowlist from a platform URL.
|
|
164
|
+
|
|
165
|
+
This is the default behavior: auto-allow the platform's KAS endpoint.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
platform_url: The OpenTDF platform URL. The KAS endpoint is
|
|
169
|
+
assumed to be at {platform_url}/kas.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
KASAllowlist configured to allow the platform's KAS endpoint.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
allowlist = cls()
|
|
176
|
+
# Add the platform URL itself (KAS might be at root or /kas)
|
|
177
|
+
allowlist.add(platform_url)
|
|
178
|
+
# Also construct the /kas endpoint explicitly
|
|
179
|
+
kas_url = platform_url.rstrip("/") + "/kas"
|
|
180
|
+
allowlist.add(kas_url)
|
|
181
|
+
logging.info(f"Created KAS allowlist from platform URL: {platform_url}")
|
|
182
|
+
return allowlist
|
otdf_python/kas_client.py
CHANGED
|
@@ -38,13 +38,26 @@ class KASClient:
|
|
|
38
38
|
cache=None,
|
|
39
39
|
use_plaintext=False,
|
|
40
40
|
verify_ssl=True,
|
|
41
|
+
kas_allowlist=None,
|
|
41
42
|
):
|
|
42
|
-
"""Initialize KAS client.
|
|
43
|
+
"""Initialize KAS client.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
kas_url: Default KAS URL
|
|
47
|
+
token_source: Function that returns an authentication token
|
|
48
|
+
cache: Optional KASKeyCache for caching public keys
|
|
49
|
+
use_plaintext: Whether to use HTTP instead of HTTPS
|
|
50
|
+
verify_ssl: Whether to verify SSL certificates
|
|
51
|
+
kas_allowlist: Optional KASAllowlist for URL validation. If provided,
|
|
52
|
+
only URLs in the allowlist will be contacted.
|
|
53
|
+
|
|
54
|
+
"""
|
|
43
55
|
self.kas_url = kas_url
|
|
44
56
|
self.token_source = token_source
|
|
45
57
|
self.cache = cache or KASKeyCache()
|
|
46
58
|
self.use_plaintext = use_plaintext
|
|
47
59
|
self.verify_ssl = verify_ssl
|
|
60
|
+
self.kas_allowlist = kas_allowlist
|
|
48
61
|
self.decryptor = None
|
|
49
62
|
self.client_public_key = None
|
|
50
63
|
|
|
@@ -65,18 +78,47 @@ class KASClient:
|
|
|
65
78
|
self._dpop_public_key
|
|
66
79
|
)
|
|
67
80
|
|
|
81
|
+
def __enter__(self):
|
|
82
|
+
"""Enter context manager."""
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
86
|
+
"""Exit context manager and clean up resources."""
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
def close(self):
|
|
90
|
+
"""Close the KAS client and release resources.
|
|
91
|
+
|
|
92
|
+
This method should be called when the client is no longer needed
|
|
93
|
+
to properly clean up resources. It's also called automatically
|
|
94
|
+
when using the client as a context manager.
|
|
95
|
+
"""
|
|
96
|
+
if self.connect_rpc_client:
|
|
97
|
+
self.connect_rpc_client.close()
|
|
98
|
+
|
|
68
99
|
def _normalize_kas_url(self, url: str) -> str:
|
|
69
100
|
"""Normalize KAS URLs based on client security settings.
|
|
70
101
|
|
|
102
|
+
This method also validates the URL against the KAS allowlist if one
|
|
103
|
+
is configured. This prevents SSRF attacks where malicious TDF files
|
|
104
|
+
could contain attacker-controlled KAS URLs to steal OIDC credentials.
|
|
105
|
+
|
|
71
106
|
Args:
|
|
72
107
|
url: The KAS URL to normalize
|
|
73
108
|
|
|
74
109
|
Returns:
|
|
75
110
|
Normalized URL with appropriate protocol and port
|
|
76
111
|
|
|
112
|
+
Raises:
|
|
113
|
+
KASAllowlistException: If the URL is not in the allowlist
|
|
114
|
+
|
|
77
115
|
"""
|
|
78
116
|
from urllib.parse import urlparse
|
|
79
117
|
|
|
118
|
+
# Validate against allowlist BEFORE making any requests
|
|
119
|
+
if self.kas_allowlist is not None:
|
|
120
|
+
self.kas_allowlist.validate(url)
|
|
121
|
+
|
|
80
122
|
try:
|
|
81
123
|
# Parse the URL
|
|
82
124
|
parsed = urlparse(url)
|
|
@@ -136,7 +178,7 @@ class KASClient:
|
|
|
136
178
|
# Reconstruct URL preserving the path (especially /kas prefix)
|
|
137
179
|
try:
|
|
138
180
|
# Create URL preserving the path component for proper endpoint routing
|
|
139
|
-
path = parsed.path
|
|
181
|
+
path = parsed.path or ""
|
|
140
182
|
normalized_url = f"{scheme}://{parsed.hostname}:{port}{path}"
|
|
141
183
|
logging.debug(f"normalized url [{parsed.geturl()}] to [{normalized_url}]")
|
|
142
184
|
return normalized_url
|
|
@@ -4,9 +4,9 @@ This class encapsulates all interactions with otdf_python_proto.
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import httpx
|
|
8
8
|
from otdf_python_proto.kas import kas_pb2
|
|
9
|
-
from otdf_python_proto.kas.
|
|
9
|
+
from otdf_python_proto.kas.kas_connect import AccessServiceClientSync
|
|
10
10
|
|
|
11
11
|
from otdf_python.auth_headers import AuthHeaders
|
|
12
12
|
|
|
@@ -26,21 +26,56 @@ class KASConnectRPCClient:
|
|
|
26
26
|
"""
|
|
27
27
|
self.use_plaintext = use_plaintext
|
|
28
28
|
self.verify_ssl = verify_ssl
|
|
29
|
+
self._http_client = None
|
|
30
|
+
|
|
31
|
+
def __enter__(self):
|
|
32
|
+
"""Enter context manager and create HTTP client."""
|
|
33
|
+
self._http_client = self._create_http_client()
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
37
|
+
"""Exit context manager and close HTTP client."""
|
|
38
|
+
self.close()
|
|
39
|
+
|
|
40
|
+
def close(self):
|
|
41
|
+
"""Close HTTP client and release resources.
|
|
42
|
+
|
|
43
|
+
This method should be called when the client is no longer needed
|
|
44
|
+
to properly clean up resources. It's also called automatically
|
|
45
|
+
when using the client as a context manager.
|
|
46
|
+
"""
|
|
47
|
+
if self._http_client is not None:
|
|
48
|
+
self._http_client.close()
|
|
49
|
+
self._http_client = None
|
|
29
50
|
|
|
30
51
|
def _create_http_client(self):
|
|
31
52
|
"""Create HTTP client with SSL verification configuration.
|
|
32
53
|
|
|
33
54
|
Returns:
|
|
34
|
-
|
|
55
|
+
httpx.Client configured for SSL verification settings
|
|
35
56
|
|
|
36
57
|
"""
|
|
37
58
|
if self.verify_ssl:
|
|
38
59
|
logging.info("Using SSL verification enabled HTTP client")
|
|
39
|
-
return
|
|
60
|
+
return httpx.Client()
|
|
40
61
|
else:
|
|
41
62
|
logging.info("Using SSL verification disabled HTTP client")
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
return httpx.Client(verify=False)
|
|
64
|
+
|
|
65
|
+
def _get_http_client(self):
|
|
66
|
+
"""Get HTTP client, creating one if needed for backward compatibility.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
httpx.Client instance
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
if self._http_client is None:
|
|
73
|
+
logging.warning(
|
|
74
|
+
"KASConnectRPCClient is being used without a context manager. "
|
|
75
|
+
"Consider using 'with KASConnectRPCClient(...) as client:' to ensure proper resource cleanup."
|
|
76
|
+
)
|
|
77
|
+
self._http_client = self._create_http_client()
|
|
78
|
+
return self._http_client
|
|
44
79
|
|
|
45
80
|
def _prepare_connect_rpc_url(self, kas_url):
|
|
46
81
|
"""Prepare the base URL for Connect RPC client.
|
|
@@ -93,8 +128,6 @@ class KASConnectRPCClient:
|
|
|
93
128
|
f"kas_url={kas_info.url}"
|
|
94
129
|
)
|
|
95
130
|
|
|
96
|
-
http_client = self._create_http_client()
|
|
97
|
-
|
|
98
131
|
try:
|
|
99
132
|
connect_rpc_base_url = self._prepare_connect_rpc_url(normalized_kas_url)
|
|
100
133
|
|
|
@@ -103,9 +136,13 @@ class KASConnectRPCClient:
|
|
|
103
136
|
f"for public key retrieval"
|
|
104
137
|
)
|
|
105
138
|
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
139
|
+
# Get or create HTTP client
|
|
140
|
+
http_client = self._get_http_client()
|
|
141
|
+
|
|
142
|
+
# Create Connect RPC client with configured HTTP client
|
|
143
|
+
client = AccessServiceClientSync(
|
|
144
|
+
address=connect_rpc_base_url, session=http_client
|
|
145
|
+
)
|
|
109
146
|
|
|
110
147
|
# Create public key request
|
|
111
148
|
algorithm = getattr(kas_info, "algorithm", "") or ""
|
|
@@ -116,10 +153,10 @@ class KASConnectRPCClient:
|
|
|
116
153
|
)
|
|
117
154
|
|
|
118
155
|
# Prepare headers with authentication if available
|
|
119
|
-
|
|
156
|
+
headers = self._prepare_auth_headers(access_token)
|
|
120
157
|
|
|
121
158
|
# Make the public key call with authentication headers
|
|
122
|
-
response = client.public_key(request,
|
|
159
|
+
response = client.public_key(request, headers=headers)
|
|
123
160
|
|
|
124
161
|
# Update kas_info with response
|
|
125
162
|
kas_info.public_key = response.public_key
|
|
@@ -158,8 +195,6 @@ class KASConnectRPCClient:
|
|
|
158
195
|
f"kas_url={key_access.url}"
|
|
159
196
|
)
|
|
160
197
|
|
|
161
|
-
http_client = self._create_http_client()
|
|
162
|
-
|
|
163
198
|
try:
|
|
164
199
|
kas_service_url = self._prepare_connect_rpc_url(normalized_kas_url)
|
|
165
200
|
|
|
@@ -167,8 +202,13 @@ class KASConnectRPCClient:
|
|
|
167
202
|
f"Creating Connect RPC client for base URL: {kas_service_url}, for unwrap"
|
|
168
203
|
)
|
|
169
204
|
|
|
170
|
-
#
|
|
171
|
-
|
|
205
|
+
# Get or create HTTP client
|
|
206
|
+
http_client = self._get_http_client()
|
|
207
|
+
|
|
208
|
+
# Create Connect RPC client with configured HTTP client
|
|
209
|
+
client = AccessServiceClientSync(
|
|
210
|
+
address=kas_service_url, session=http_client
|
|
211
|
+
)
|
|
172
212
|
|
|
173
213
|
# Create rewrap request
|
|
174
214
|
request = kas_pb2.RewrapRequest(
|
|
@@ -179,10 +219,10 @@ class KASConnectRPCClient:
|
|
|
179
219
|
logging.info(f"Connect RPC signed token: {signed_token}")
|
|
180
220
|
|
|
181
221
|
# Prepare headers with authentication if available
|
|
182
|
-
|
|
222
|
+
headers = self._prepare_auth_headers(access_token)
|
|
183
223
|
|
|
184
224
|
# Make the rewrap call with authentication headers
|
|
185
|
-
response = client.rewrap(request,
|
|
225
|
+
response = client.rewrap(request, headers=headers)
|
|
186
226
|
|
|
187
227
|
# Extract the entity wrapped key from v2 response structure
|
|
188
228
|
# The v2 response has responses[] array with results[] for each policy
|
otdf_python/nanotdf.py
CHANGED
|
@@ -28,26 +28,18 @@ from .asym_crypto import AsymDecryption
|
|
|
28
28
|
class NanoTDFException(SDKException):
|
|
29
29
|
"""Base exception for NanoTDF operations."""
|
|
30
30
|
|
|
31
|
-
pass
|
|
32
|
-
|
|
33
31
|
|
|
34
32
|
class NanoTDFMaxSizeLimit(NanoTDFException):
|
|
35
33
|
"""Exception for NanoTDF size limit exceeded."""
|
|
36
34
|
|
|
37
|
-
pass
|
|
38
|
-
|
|
39
35
|
|
|
40
36
|
class UnsupportedNanoTDFFeature(NanoTDFException):
|
|
41
37
|
"""Exception for unsupported NanoTDF features."""
|
|
42
38
|
|
|
43
|
-
pass
|
|
44
|
-
|
|
45
39
|
|
|
46
40
|
class InvalidNanoTDFConfig(NanoTDFException):
|
|
47
41
|
"""Exception for invalid NanoTDF configuration."""
|
|
48
42
|
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
43
|
|
|
52
44
|
class NanoTDF:
|
|
53
45
|
"""NanoTDF reader and writer for compact TDF format."""
|
|
@@ -78,8 +70,8 @@ class NanoTDF:
|
|
|
78
70
|
if isinstance(obj, PolicyBody):
|
|
79
71
|
# Convert data_attributes to dataAttributes and use null instead of empty array
|
|
80
72
|
result = {
|
|
81
|
-
"dataAttributes": obj.data_attributes
|
|
82
|
-
"dissem": obj.dissem
|
|
73
|
+
"dataAttributes": obj.data_attributes or None,
|
|
74
|
+
"dissem": obj.dissem or None,
|
|
83
75
|
}
|
|
84
76
|
return result
|
|
85
77
|
elif isinstance(obj, AttributeObject):
|
|
@@ -123,14 +115,12 @@ class NanoTDF:
|
|
|
123
115
|
tuple: (policy_body, policy_type)
|
|
124
116
|
|
|
125
117
|
"""
|
|
126
|
-
attributes = config.attributes
|
|
118
|
+
attributes = config.attributes or []
|
|
127
119
|
policy_object = self._create_policy_object(attributes)
|
|
128
120
|
policy_json = json.dumps(
|
|
129
121
|
policy_object, default=self._serialize_policy_object
|
|
130
122
|
).encode("utf-8")
|
|
131
|
-
policy_type =
|
|
132
|
-
config.policy_type if config.policy_type else "EMBEDDED_POLICY_PLAIN_TEXT"
|
|
133
|
-
)
|
|
123
|
+
policy_type = config.policy_type or "EMBEDDED_POLICY_PLAIN_TEXT"
|
|
134
124
|
|
|
135
125
|
if policy_type == "EMBEDDED_POLICY_PLAIN_TEXT":
|
|
136
126
|
policy_body = policy_json
|