Skip to content

Commit c5c83e3

Browse files
authored
Merge branch 'main' into christo/manifest-server-errors
2 parents 9df9c67 + 7ab013d commit c5c83e3

File tree

16 files changed

+794
-237
lines changed

16 files changed

+794
-237
lines changed

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -459,25 +459,60 @@ def _print_ci_secrets_masks(
459459
_print_ci_secrets_masks_for_config(config=config_dict)
460460

461461

462+
def _print_ci_secret_mask_for_string(secret: str) -> None:
463+
"""Print GitHub CI mask for a single secret string.
464+
465+
We expect single-line secrets, but we also handle the case where the secret contains newlines.
466+
For multi-line secrets, we must print a secret mask for each line separately.
467+
"""
468+
for line in secret.splitlines():
469+
if line.strip(): # Skip empty lines
470+
print(f"::add-mask::{line!s}")
471+
472+
473+
def _print_ci_secret_mask_for_value(value: Any) -> None:
474+
"""Print GitHub CI mask for a single secret value.
475+
476+
Call this function for any values identified as secrets, regardless of type.
477+
"""
478+
if isinstance(value, dict):
479+
# For nested dicts, we call recursively on each value
480+
for v in value.values():
481+
_print_ci_secret_mask_for_value(v)
482+
483+
return
484+
485+
if isinstance(value, list):
486+
# For lists, we call recursively on each list item
487+
for list_item in value:
488+
_print_ci_secret_mask_for_value(list_item)
489+
490+
return
491+
492+
# For any other types besides dict and list, we convert to string and mask each line
493+
# separately to handle multi-line secrets (e.g. private keys).
494+
for line in str(value).splitlines():
495+
if line.strip(): # Skip empty lines
496+
_print_ci_secret_mask_for_string(line)
497+
498+
462499
def _print_ci_secrets_masks_for_config(
463500
config: dict[str, str] | list[Any] | Any,
464501
) -> None:
465502
"""Print GitHub CI mask for secrets config, navigating child nodes recursively."""
466503
if isinstance(config, list):
504+
# Check each item in the list to look for nested dicts that may contain secrets:
467505
for item in config:
468506
_print_ci_secrets_masks_for_config(item)
469507

470-
if isinstance(config, dict):
508+
elif isinstance(config, dict):
471509
for key, value in config.items():
472510
if _is_secret_property(key):
473511
logger.debug(f"Masking secret for config key: {key}")
474-
print(f"::add-mask::{value!s}")
475-
if isinstance(value, dict):
476-
# For nested dicts, we also need to mask the json-stringified version
477-
print(f"::add-mask::{json.dumps(value)!s}")
478-
479-
if isinstance(value, (dict, list)):
480-
_print_ci_secrets_masks_for_config(config=value)
512+
_print_ci_secret_mask_for_value(value)
513+
elif isinstance(value, (dict, list)):
514+
# Recursively check nested dicts and lists
515+
_print_ci_secrets_masks_for_config(value)
481516

482517

483518
def _is_secret_property(property_name: str) -> bool:

airbyte_cdk/sources/declarative/auth/jwt.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,28 @@
66
import json
77
from dataclasses import InitVar, dataclass
88
from datetime import datetime
9-
from typing import Any, Mapping, Optional, Union
9+
from typing import Any, Mapping, MutableMapping, Optional, Union, cast
1010

1111
import jwt
12+
from cryptography.hazmat.primitives import serialization
13+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
14+
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
15+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
16+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
1217

1318
from airbyte_cdk.sources.declarative.auth.declarative_authenticator import DeclarativeAuthenticator
1419
from airbyte_cdk.sources.declarative.interpolation.interpolated_boolean import InterpolatedBoolean
1520
from airbyte_cdk.sources.declarative.interpolation.interpolated_mapping import InterpolatedMapping
1621
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
22+
from airbyte_cdk.sources.declarative.requesters.request_option import (
23+
RequestOption,
24+
RequestOptionType,
25+
)
26+
27+
# Type alias for keys that JWT library accepts
28+
JwtKeyTypes = Union[
29+
RSAPrivateKey, EllipticCurvePrivateKey, Ed25519PrivateKey, Ed448PrivateKey, str, bytes
30+
]
1731

1832

1933
class JwtAlgorithm(str):
@@ -74,6 +88,8 @@ class JwtAuthenticator(DeclarativeAuthenticator):
7488
aud: Optional[Union[InterpolatedString, str]] = None
7589
additional_jwt_headers: Optional[Mapping[str, Any]] = None
7690
additional_jwt_payload: Optional[Mapping[str, Any]] = None
91+
passphrase: Optional[Union[InterpolatedString, str]] = None
92+
request_option: Optional[RequestOption] = None
7793

7894
def __post_init__(self, parameters: Mapping[str, Any]) -> None:
7995
self._secret_key = InterpolatedString.create(self.secret_key, parameters=parameters)
@@ -103,6 +119,18 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None:
103119
self._additional_jwt_payload = InterpolatedMapping(
104120
self.additional_jwt_payload or {}, parameters=parameters
105121
)
122+
self._passphrase = (
123+
InterpolatedString.create(self.passphrase, parameters=parameters)
124+
if self.passphrase
125+
else None
126+
)
127+
128+
# When we first implemented the JWT authenticator, we assumed that the signed token was always supposed
129+
# to be loaded into the request headers under the `Authorization` key. This is not always the case, but
130+
# this default option allows for backwards compatibility to be retained for existing connectors
131+
self._request_option = self.request_option or RequestOption(
132+
inject_into=RequestOptionType.header, field_name="Authorization", parameters=parameters
133+
)
106134

107135
def _get_jwt_headers(self) -> dict[str, Any]:
108136
"""
@@ -149,11 +177,21 @@ def _get_jwt_payload(self) -> dict[str, Any]:
149177
payload["nbf"] = nbf
150178
return payload
151179

152-
def _get_secret_key(self) -> str:
180+
def _get_secret_key(self) -> JwtKeyTypes:
153181
"""
154182
Returns the secret key used to sign the JWT.
155183
"""
156184
secret_key: str = self._secret_key.eval(self.config, json_loads=json.loads)
185+
186+
if self._passphrase:
187+
passphrase_value = self._passphrase.eval(self.config, json_loads=json.loads)
188+
if passphrase_value:
189+
private_key = serialization.load_pem_private_key(
190+
secret_key.encode(),
191+
password=passphrase_value.encode(),
192+
)
193+
return cast(JwtKeyTypes, private_key)
194+
157195
return (
158196
base64.b64encode(secret_key.encode()).decode()
159197
if self._base64_encode_secret_key
@@ -186,7 +224,8 @@ def _get_header_prefix(self) -> Union[str, None]:
186224

187225
@property
188226
def auth_header(self) -> str:
189-
return "Authorization"
227+
options = self._get_request_options(RequestOptionType.header)
228+
return next(iter(options.keys()), "")
190229

191230
@property
192231
def token(self) -> str:
@@ -195,3 +234,18 @@ def token(self) -> str:
195234
if self._get_header_prefix()
196235
else self._get_signed_token()
197236
)
237+
238+
def get_request_params(self) -> Mapping[str, Any]:
239+
return self._get_request_options(RequestOptionType.request_parameter)
240+
241+
def get_request_body_data(self) -> Union[Mapping[str, Any], str]:
242+
return self._get_request_options(RequestOptionType.body_data)
243+
244+
def get_request_body_json(self) -> Mapping[str, Any]:
245+
return self._get_request_options(RequestOptionType.body_json)
246+
247+
def _get_request_options(self, option_type: RequestOptionType) -> Mapping[str, Any]:
248+
options: MutableMapping[str, Any] = {}
249+
if self._request_option.inject_into == option_type:
250+
self._request_option.inject_into_request(options, self.token, self.config)
251+
return options

airbyte_cdk/sources/declarative/declarative_component_schema.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,16 @@ definitions:
12701270
title: Additional JWT Payload Properties
12711271
description: Additional properties to be added to the JWT payload.
12721272
additionalProperties: true
1273+
passphrase:
1274+
title: Passphrase
1275+
description: A passphrase/password used to encrypt the private key. Only provide a passphrase if required by the API for JWT authentication. The API will typically provide the passphrase when generating the public/private key pair.
1276+
type: string
1277+
examples:
1278+
- "{{ config['passphrase'] }}"
1279+
request_option:
1280+
title: Request Option
1281+
description: A request option describing where the signed JWT token that is generated should be injected into the outbound API request.
1282+
"$ref": "#/definitions/RequestOption"
12731283
$parameters:
12741284
type: object
12751285
additionalProperties: true

airbyte_cdk/sources/declarative/interpolation/macros.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import datetime
77
import re
88
import typing
9+
import uuid
910
from typing import Optional, Union
1011
from urllib.parse import quote_plus
1112

@@ -207,6 +208,16 @@ def camel_case_to_snake_case(value: str) -> str:
207208
return re.sub(r"(?<!^)(?=[A-Z])", "_", value).lower()
208209

209210

211+
def generate_uuid() -> str:
212+
"""
213+
Generates a UUID4
214+
215+
Usage:
216+
`"{{ generate_uuid() }}"`
217+
"""
218+
return str(uuid.uuid4())
219+
220+
210221
_macros_list = [
211222
now_utc,
212223
today_utc,
@@ -220,5 +231,6 @@ def camel_case_to_snake_case(value: str) -> str:
220231
str_to_datetime,
221232
sanitize_url,
222233
camel_case_to_snake_case,
234+
generate_uuid,
223235
]
224236
macros = {f.__name__: f for f in _macros_list}

0 commit comments

Comments
 (0)