Skip to content

Commit 253851e

Browse files
authored
feat: add secrets support for tap and target config, via Property(..., secret=True) (#1096)
1 parent c699c72 commit 253851e

File tree

10 files changed

+267
-13
lines changed

10 files changed

+267
-13
lines changed

cookiecutter/tap-template/{{cookiecutter.tap_id}}/{{cookiecutter.library_name}}/tap.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Tap{{ cookiecutter.source_name }}({{ 'SQL' if cookiecutter.stream_type ==
3838
"auth_token",
3939
th.StringType,
4040
required=True,
41+
secret=True, # Flag config as protected.
4142
description="The token to authenticate against the API service"
4243
),
4344
th.Property(

cookiecutter/target-template/{{cookiecutter.target_id}}/{{cookiecutter.library_name}}/target.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Target{{ cookiecutter.destination_name }}({{ target_class }}):
2121
th.Property(
2222
"sqlalchemy_url",
2323
th.StringType,
24+
secret=True, # Flag config as protected.
2425
description="SQLAlchemy connection string",
2526
),
2627
{%- else %}
@@ -34,6 +35,12 @@ class Target{{ cookiecutter.destination_name }}({{ target_class }}):
3435
th.StringType,
3536
description="The scheme with which output files will be named"
3637
),
38+
th.Property(
39+
"auth_token",
40+
th.StringType,
41+
secret=True, # Flag config as protected.
42+
description="The path to the target output file"
43+
),
3744
{%- endif %}
3845
).to_dict()
3946

docs/faq.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ However, if you're using an IDE such as VSCode, you should be able to set up the
1111
Ensure your interpreter is set to poetry if you've followed the [Dev Guide](./dev_guide.md).
1212
Checkout this [gif](https://visualstudiomagazine.com/articles/2021/04/20/~/media/ECG/visualstudiomagazine/Images/2021/04/poetry.ashx) for how to change your interpreter.
1313

14-
## I'm having trouble getting the base class to __init__.
14+
### Handling credentials and other secrets in config
15+
16+
As of SDK version `0.13.0`, developers can use the `secret=True` indication in the `Property` class constructor to flag secrets such as API tokens and passwords. We recommend all developers use this option where applicable so that orchestrators may consider this designation when determining how to store the user's provided config.
17+
18+
## I'm having trouble getting the base class to **init**.
1519

1620
Ensure you're using the `super()` method to inherit methods from the base class.
1721

samples/sample_tap_gitlab/gitlab_tap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class SampleTapGitlab(Tap):
3434

3535
name: str = "sample-tap-gitlab"
3636
config_jsonschema = PropertiesList(
37-
Property("auth_token", StringType, required=True),
37+
Property("auth_token", StringType, required=True, secret=True),
3838
Property("project_ids", ArrayType(StringType), required=True),
3939
Property("group_ids", ArrayType(StringType), required=True),
4040
Property("start_date", DateTimeType, required=True),

samples/sample_tap_google_analytics/ga_tap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class SampleTapGoogleAnalytics(Tap):
2424
config_jsonschema = PropertiesList(
2525
Property("view_id", StringType(), required=True),
2626
Property("client_email", StringType(), required=True),
27-
Property("private_key", StringType(), required=True),
27+
Property("private_key", StringType(), required=True, secret=True),
2828
).to_dict()
2929

3030
def discover_streams(self) -> List[SampleGoogleAnalyticsStream]:

singer_sdk/helpers/_typing.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
_MAX_TIMESTAMP = "9999-12-31 23:59:59.999999"
1313
_MAX_TIME = "23:59:59.999999"
14+
JSONSCHEMA_ANNOTATION_SECRET = "secret"
15+
JSONSCHEMA_ANNOTATION_WRITEONLY = "writeOnly"
1416

1517

1618
class DatetimeErrorTreatmentEnum(Enum):
@@ -54,6 +56,36 @@ def append_type(type_dict: dict, new_type: str) -> dict:
5456
)
5557

5658

59+
def is_secret_type(type_dict: dict) -> bool:
60+
"""Return True if JSON Schema type definition appears to be a secret.
61+
62+
Will return true if either `writeOnly` or `secret` are true on this type
63+
or any of the type's subproperties.
64+
65+
Args:
66+
type_dict: The JSON Schema type to check.
67+
68+
Raises:
69+
ValueError: If type_dict is None or empty.
70+
71+
Returns:
72+
True if we detect any sensitive property nodes.
73+
"""
74+
if type_dict.get(JSONSCHEMA_ANNOTATION_WRITEONLY) or type_dict.get(
75+
JSONSCHEMA_ANNOTATION_SECRET
76+
):
77+
return True
78+
79+
if "properties" in type_dict:
80+
# Recursively check subproperties and return True if any child is secret.
81+
return any(
82+
is_secret_type(child_type_dict)
83+
for child_type_dict in type_dict["properties"].values()
84+
)
85+
86+
return False
87+
88+
5789
def is_object_type(property_schema: dict) -> Optional[bool]:
5890
"""Return true if the JSON Schema type is an object or None if detection fails."""
5991
if "anyOf" not in property_schema and "type" not in property_schema:
@@ -86,6 +118,34 @@ def is_datetime_type(type_dict: dict) -> bool:
86118
)
87119

88120

121+
def is_date_or_datetime_type(type_dict: dict) -> bool:
122+
"""Return True if JSON Schema type definition is a 'date'/'date-time' type.
123+
124+
Also returns True if type is nested within an 'anyOf' type Array.
125+
126+
Args:
127+
type_dict: The JSON Schema definition.
128+
129+
Raises:
130+
ValueError: If type is empty or null.
131+
132+
Returns:
133+
True if date or date-time, else False.
134+
"""
135+
if "anyOf" in type_dict:
136+
for type_dict in type_dict["anyOf"]:
137+
if is_date_or_datetime_type(type_dict):
138+
return True
139+
return False
140+
141+
if "type" in type_dict:
142+
return type_dict.get("format") in {"date", "date-time"}
143+
144+
raise ValueError(
145+
f"Could not detect type of replication key using schema '{type_dict}'"
146+
)
147+
148+
89149
def get_datelike_property_type(property_schema: Dict) -> Optional[str]:
90150
"""Return one of 'date-time', 'time', or 'date' if property is date-like.
91151
@@ -152,6 +212,23 @@ def is_string_array_type(type_dict: dict) -> bool:
152212
return "array" in type_dict["type"] and bool(is_string_type(type_dict["items"]))
153213

154214

215+
def is_array_type(type_dict: dict) -> bool:
216+
"""Return True if JSON Schema type definition is a string array."""
217+
if not type_dict:
218+
raise ValueError(
219+
"Could not detect type from empty type_dict. "
220+
"Did you forget to define a property in the stream schema?"
221+
)
222+
223+
if "anyOf" in type_dict:
224+
return any([is_array_type(t) for t in type_dict["anyOf"]])
225+
226+
if "type" not in type_dict:
227+
raise ValueError(f"Could not detect type from schema '{type_dict}'")
228+
229+
return "array" in type_dict["type"]
230+
231+
155232
def is_boolean_type(property_schema: dict) -> Optional[bool]:
156233
"""Return true if the JSON Schema type is a boolean or None if detection fails."""
157234
if "anyOf" not in property_schema and "type" not in property_schema:
@@ -162,6 +239,16 @@ def is_boolean_type(property_schema: dict) -> Optional[bool]:
162239
return False
163240

164241

242+
def is_integer_type(property_schema: dict) -> Optional[bool]:
243+
"""Return true if the JSON Schema type is a boolean or None if detection fails."""
244+
if "anyOf" not in property_schema and "type" not in property_schema:
245+
return None # Could not detect data type
246+
for property_type in property_schema.get("anyOf", [property_schema.get("type")]):
247+
if "integer" in property_type or property_type == "integer":
248+
return True
249+
return False
250+
251+
165252
def is_string_type(property_schema: dict) -> Optional[bool]:
166253
"""Return true if the JSON Schema type is a boolean or None if detection fails."""
167254
if "anyOf" not in property_schema and "type" not in property_schema:

singer_sdk/plugin_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
)
2222

2323
import click
24-
from jsonschema import Draft4Validator, SchemaError, ValidationError
24+
from jsonschema import Draft7Validator, SchemaError, ValidationError
2525

2626
from singer_sdk import metrics
2727
from singer_sdk.configuration._dict_config import parse_environment_config
@@ -42,7 +42,7 @@
4242
SDK_PACKAGE_NAME = "singer_sdk"
4343

4444

45-
JSONSchemaValidator = extend_validator_with_defaults(Draft4Validator)
45+
JSONSchemaValidator = extend_validator_with_defaults(Draft7Validator)
4646

4747

4848
class PluginBase(metaclass=abc.ABCMeta):

singer_sdk/sinks/core.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from typing import IO, Any, Mapping, Sequence
1414

1515
from dateutil import parser
16-
from jsonschema import Draft4Validator, FormatChecker
16+
from jsonschema import Draft7Validator, FormatChecker
1717

1818
from singer_sdk.helpers._batch import (
1919
BaseBatchFileEncoding,
@@ -29,7 +29,7 @@
2929
)
3030
from singer_sdk.plugin_base import PluginBase
3131

32-
JSONSchemaValidator = Draft4Validator
32+
JSONSchemaValidator = Draft7Validator
3333

3434

3535
class Sink(metaclass=abc.ABCMeta):
@@ -80,7 +80,7 @@ def __init__(
8080
self._batch_records_read: int = 0
8181
self._batch_dupe_records_merged: int = 0
8282

83-
self._validator = Draft4Validator(schema, format_checker=FormatChecker())
83+
self._validator = Draft7Validator(schema, format_checker=FormatChecker())
8484

8585
def _get_context(self, record: dict) -> dict:
8686
"""Return an empty dictionary by default.

singer_sdk/typing.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@
4848
from jsonschema import validators
4949

5050
from singer_sdk.helpers._classproperty import classproperty
51-
from singer_sdk.helpers._typing import append_type, get_datelike_property_type
51+
from singer_sdk.helpers._typing import (
52+
JSONSCHEMA_ANNOTATION_SECRET,
53+
JSONSCHEMA_ANNOTATION_WRITEONLY,
54+
append_type,
55+
get_datelike_property_type,
56+
)
5257

5358
if sys.version_info >= (3, 10):
5459
from typing import TypeAlias
@@ -352,21 +357,30 @@ def __init__(
352357
required: bool = False,
353358
default: _JsonValue = None,
354359
description: str = None,
360+
secret: bool = False,
355361
) -> None:
356362
"""Initialize Property object.
357363
364+
Note: Properties containing secrets should be specified with `secret=True`.
365+
Doing so will add the annotation `writeOnly=True`, in accordance with JSON
366+
Schema Draft 7 and later, and `secret=True` as an additional hint to readers.
367+
368+
More info: https://json-schema.org/draft-07/json-schema-release-notes.html
369+
358370
Args:
359371
name: Property name.
360372
wrapped: JSON Schema type of the property.
361373
required: Whether this is a required property.
362374
default: Default value in the JSON Schema.
363375
description: Long-text property description.
376+
secret: True if this is a credential or other secret.
364377
"""
365378
self.name = name
366379
self.wrapped = wrapped
367380
self.optional = not required
368381
self.default = default
369382
self.description = description
383+
self.secret = secret
370384

371385
@property
372386
def type_dict(self) -> dict: # type: ignore # OK: @classproperty vs @property
@@ -402,6 +416,13 @@ def to_dict(self) -> dict:
402416
type_dict.update({"default": self.default})
403417
if self.description:
404418
type_dict.update({"description": self.description})
419+
if self.secret:
420+
type_dict.update(
421+
{
422+
JSONSCHEMA_ANNOTATION_SECRET: True,
423+
JSONSCHEMA_ANNOTATION_WRITEONLY: True,
424+
}
425+
)
405426
return {self.name: type_dict}
406427

407428

0 commit comments

Comments
 (0)