Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add integration tests for IAM User auth #774

Merged
merged 40 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
1175207
move connection fixtures into the functional scope
mikealfare Apr 16, 2024
d85f503
add iam user creds to the test.env template
mikealfare Apr 16, 2024
4255abe
add test for database connection method
mikealfare Apr 16, 2024
6eaa5ea
add iam user auth test
mikealfare Apr 18, 2024
8addeea
Merge branch 'refs/heads/main' into iam-auth
mikealfare Apr 19, 2024
736fd1e
add IAM User auth test and second user auth method
mikealfare Apr 19, 2024
effdb6b
changie
mikealfare Apr 19, 2024
6d57662
Merge branch 'main' into iam-auth
mikealfare Apr 19, 2024
4c3dd3f
maintain existing behavior when not providing profile
mikealfare Apr 19, 2024
9a08786
Merge remote-tracking branch 'origin/iam-auth' into iam-auth
mikealfare Apr 19, 2024
f87601f
add AWS IAM profile
mikealfare Apr 19, 2024
d55b6ee
pull in new env vars
mikealfare Apr 19, 2024
0501c53
fixed env vars refs for CI
mikealfare Apr 19, 2024
f3697a7
move all repo vars to secrets
mikealfare Apr 19, 2024
e97a2f6
Merge branch 'refs/heads/main' into iam-auth
mikealfare Apr 23, 2024
dbeb882
split out connect method by connection method and provided information
mikealfare Apr 23, 2024
1f64811
condition to produce just kwargs, consolidate connect method
mikealfare Apr 23, 2024
228e318
update .format to f-strings
mikealfare Apr 23, 2024
12b7239
updates to make space for iam role
mikealfare Apr 23, 2024
8336788
make space for both iam user and iam role in testing
mikealfare Apr 23, 2024
5c8bea7
Merge branch 'refs/heads/main' into iam-auth
mikealfare Apr 23, 2024
b72a42e
naming
mikealfare Apr 24, 2024
57f85a4
try supplying region for CI
mikealfare Apr 24, 2024
a3fb3a1
Merge remote-tracking branch 'refs/remotes/origin/main' into iam-auth
mikealfare Apr 24, 2024
316650b
add region to CI env
mikealfare Apr 24, 2024
aa1ca4d
Merge branch 'main' into iam-auth
mikealfare Apr 24, 2024
b3c1c1a
move iam user specific config out of iam and into iam user
mikealfare Apr 24, 2024
26f7912
add type annotations
mikealfare Apr 24, 2024
e2acfc3
move iam defaults out of iam user
mikealfare Apr 24, 2024
83f9c66
add required params to test profiles
mikealfare Apr 24, 2024
050ab08
simplify test files
mikealfare Apr 25, 2024
ec9e34e
add expected fields back in
mikealfare Apr 25, 2024
8e0f654
split out unit test files
mikealfare Apr 25, 2024
4c01ea1
split out unit test files
mikealfare Apr 25, 2024
dcc4f0c
standardize names
mikealfare Apr 25, 2024
fe7705a
Merge branch 'main' into iam-auth
colin-rogers-dbt Apr 25, 2024
3fcd320
Merge branch 'main' into iam-auth
mikealfare Apr 26, 2024
bc0107d
Merge branch 'main' into iam-auth
colin-rogers-dbt May 3, 2024
e4b075b
Merge branch 'main' into iam-auth
mikealfare May 3, 2024
146cdda
Merge branch 'main' into iam-auth
mikealfare May 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/unreleased/Features-20240419-145208.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
kind: Features
body: Support IAM user auth via direct parameters, in addition to the existing profile
method
time: 2024-04-19T14:52:08.086607-04:00
custom:
Author: mikealfare
Issue: "760"
140 changes: 86 additions & 54 deletions dbt/adapters/redshift/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ class RedshiftCredentials(Credentials):
region: Optional[str] = None
# opt-in by default per team deliberation on https://peps.python.org/pep-0249/#autocommit
autocommit: Optional[bool] = True
access_key_id: Optional[str] = None
mikealfare marked this conversation as resolved.
Show resolved Hide resolved
secret_access_key: Optional[str] = None

_ALIASES = {"dbname": "database", "pass": "password"}

Expand All @@ -142,14 +144,14 @@ def _connection_keys(self):
"region",
"sslmode",
"region",
"iam_profile",
mikealfare marked this conversation as resolved.
Show resolved Hide resolved
"autocreate",
"db_groups",
"ra3_node",
"connect_timeout",
"role",
"retries",
"autocommit",
"access_key_id",
)

@property
Expand All @@ -164,7 +166,88 @@ def __init__(self, credentials):
self.credentials = credentials

def get_connect_method(self):
method = self.credentials.method

# Support missing 'method' for backwards compatibility
method = self.credentials.method or RedshiftConnectionMethod.DATABASE
if method == RedshiftConnectionMethod.DATABASE:
kwargs = self._database_kwargs
elif method == RedshiftConnectionMethod.IAM:
kwargs = self._iam_user_kwargs
else:
raise FailedToConnectError(f"Invalid 'method' in profile: '{method}'")

def connect():
c = redshift_connector.connect(**kwargs)
if self.credentials.autocommit:
c.autocommit = True
if self.credentials.role:
c.cursor().execute(f"set role {self.credentials.role}")
return c

return connect

@property
def _database_kwargs(self):
logger.debug("Connecting to redshift with 'database' credentials method")
kwargs = self._base_kwargs

if self.credentials.user and self.credentials.password:
kwargs.update(
user=self.credentials.user,
password=self.credentials.password,
)
else:
raise FailedToConnectError(
"'user' and 'password' fields are required for 'database' credentials method"
)

return kwargs

@property
def _iam_user_kwargs(self):
logger.debug("Connecting to redshift with 'iam' credentials method")
kwargs = self._iam_kwargs
kwargs.update(user="", password="")

if user := self.credentials.user:
kwargs.update(db_user=user)
else:
raise FailedToConnectError("'user' field is required for 'iam' credentials method")

return kwargs

@property
def _iam_kwargs(self):
kwargs = self._base_kwargs
kwargs.update(iam=True)

if cluster_id := self.credentials.cluster_id:
kwargs.update(cluster_identifier=cluster_id)
elif "serverless" in self.credentials.host:
kwargs.update(cluster_identifier=None)
else:
raise FailedToConnectError(
"Failed to use IAM method:"
" 'cluster_id' must be provided for provisioned cluster"
" 'host' must be provided for serverless endpoint"
)

if self.credentials.access_key_id and self.credentials.secret_access_key:
kwargs.update(
access_key_id=self.credentials.access_key_id,
secret_access_key=self.credentials.secret_access_key,
)
elif self.credentials.access_key_id or self.credentials.secret_access_key:
raise FailedToConnectError(
"'access_key_id' and 'secret_access_key' are both needed if providing explicit credentials"
)
else:
kwargs.update(profile=self.credentials.iam_profile)

return kwargs

@property
def _base_kwargs(self):
kwargs = {
"host": self.credentials.host,
"database": self.credentials.database,
Expand All @@ -174,60 +257,9 @@ def get_connect_method(self):
"region": self.credentials.region,
"timeout": self.credentials.connect_timeout,
}

redshift_ssl_config = RedshiftSSLConfig.parse(self.credentials.sslmode)
kwargs.update(redshift_ssl_config.to_dict())

# Support missing 'method' for backwards compatibility
if method == RedshiftConnectionMethod.DATABASE or method is None:
# this requirement is really annoying to encode into json schema,
# so validate it here
if self.credentials.password is None:
raise FailedToConnectError(
"'password' field is required for 'database' credentials"
)

def connect():
logger.debug("Connecting to redshift with username/password based auth...")
c = redshift_connector.connect(
user=self.credentials.user,
password=self.credentials.password,
**kwargs,
)
if self.credentials.autocommit:
c.autocommit = True
if self.credentials.role:
c.cursor().execute("set role {}".format(self.credentials.role))
return c

elif method == RedshiftConnectionMethod.IAM:
if not self.credentials.cluster_id and "serverless" not in self.credentials.host:
raise FailedToConnectError(
"Failed to use IAM method. 'cluster_id' must be provided for provisioned cluster. "
"'host' must be provided for serverless endpoint."
)

def connect():
logger.debug("Connecting to redshift with IAM based auth...")
c = redshift_connector.connect(
iam=True,
db_user=self.credentials.user,
password="",
user="",
cluster_identifier=self.credentials.cluster_id,
profile=self.credentials.iam_profile,
**kwargs,
)
if self.credentials.autocommit:
c.autocommit = True
if self.credentials.role:
c.cursor().execute("set role {}".format(self.credentials.role))
return c

else:
raise FailedToConnectError("Invalid 'method' in profile: '{}'".format(method))

return connect
return kwargs


class RedshiftConnectionManager(SQLConnectionManager):
Expand Down
22 changes: 12 additions & 10 deletions test.env.example
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# Note: Make sure you have a Redshift account that is set up so these fields are easy to complete.

### Test Environment field definitions
# These will all be gathered from account information or created by you.
# Endpoint for Redshift connection

# Database Authentication Method
REDSHIFT_TEST_HOST=
# Username on your account
REDSHIFT_TEST_USER=
# Password for Redshift account
REDSHIFT_TEST_PASS=
# Local port to connect on
REDSHIFT_TEST_PORT=
# Name of Redshift database in your account to test against
REDSHIFT_TEST_DBNAME=
# Users for testing
REDSHIFT_TEST_USER=
REDSHIFT_TEST_PASS=

# IAM User Authentication Method
REDSHIFT_TEST_CLUSTER_ID=
REDSHIFT_TEST_IAM_USER_PROFILE=
REDSHIFT_TEST_IAM_USER_ACCESS_KEY_ID=
REDSHIFT_TEST_IAM_USER_SECRET_ACCESS_KEY=

# Database users for testing
DBT_TEST_USER_1=dbt_test_user_1
DBT_TEST_USER_2=dbt_test_user_2
DBT_TEST_USER_3=dbt_test_user_3
21 changes: 0 additions & 21 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,22 +1 @@
import pytest
import os

# Import the functional fixtures as a plugin
# Note: fixtures with session scope need to be local

pytest_plugins = ["dbt.tests.fixtures.project"]


# The profile dictionary, used to write out profiles.yml
@pytest.fixture(scope="class")
def dbt_profile_target():
return {
mikealfare marked this conversation as resolved.
Show resolved Hide resolved
"type": "redshift",
"threads": 1,
"retries": 6,
"host": os.getenv("REDSHIFT_TEST_HOST"),
"port": int(os.getenv("REDSHIFT_TEST_PORT")),
"user": os.getenv("REDSHIFT_TEST_USER"),
"pass": os.getenv("REDSHIFT_TEST_PASS"),
"dbname": os.getenv("REDSHIFT_TEST_DBNAME"),
}
18 changes: 18 additions & 0 deletions tests/functional/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import os

import pytest


# The profile dictionary, used to write out profiles.yml
@pytest.fixture(scope="class")
def dbt_profile_target():
mikealfare marked this conversation as resolved.
Show resolved Hide resolved
return {
"type": "redshift",
"threads": 1,
"retries": 6,
"host": os.getenv("REDSHIFT_TEST_HOST"),
"port": int(os.getenv("REDSHIFT_TEST_PORT")),
"user": os.getenv("REDSHIFT_TEST_USER"),
"pass": os.getenv("REDSHIFT_TEST_PASS"),
"dbname": os.getenv("REDSHIFT_TEST_DBNAME"),
}
Empty file.
10 changes: 10 additions & 0 deletions tests/functional/test_auth_methods/files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
MY_SEED = """
id,name
1,apple
2,banana
3,cherry
""".strip()

MY_VIEW = """
select * from {{ ref("my_seed") }}
"""
37 changes: 37 additions & 0 deletions tests/functional/test_auth_methods/test_database_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os

import pytest

from dbt.adapters.redshift.connections import RedshiftConnectionMethod
from dbt.tests.util import run_dbt

from tests.functional.test_auth_methods import files


class TestDatabaseAuth:
@pytest.fixture(scope="class")
def dbt_profile_target(self):
return {
"type": "redshift",
"method": RedshiftConnectionMethod.DATABASE.value,
"host": os.getenv("REDSHIFT_TEST_HOST"),
"port": int(os.getenv("REDSHIFT_TEST_PORT")),
"dbname": os.getenv("REDSHIFT_TEST_DBNAME"),
"user": os.getenv("REDSHIFT_TEST_USER"),
"pass": os.getenv("REDSHIFT_TEST_PASS"),
"threads": 1,
"retries": 6,
}

@pytest.fixture(scope="class")
def seeds(self):
yield {"my_seed.csv": files.MY_SEED}

@pytest.fixture(scope="class")
def models(self):
yield {"my_view.sql": files.MY_VIEW}

def test_connection(self, project):
run_dbt(["seed"])
results = run_dbt(["run"])
assert len(results) == 1
61 changes: 61 additions & 0 deletions tests/functional/test_auth_methods/test_iam_user_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import os

import pytest

from dbt.adapters.redshift.connections import RedshiftConnectionMethod
from dbt.tests.util import run_dbt

from tests.functional.test_auth_methods import files


class IAMUserAuth:
@pytest.fixture(scope="class")
def seeds(self):
yield {"my_seed.csv": files.MY_SEED}

@pytest.fixture(scope="class")
def models(self):
yield {"my_view.sql": files.MY_VIEW}

def test_connection(self, project):
run_dbt(["seed"])
results = run_dbt(["run"])
assert len(results) == 1


class TestIAMUserAuthProfile(IAMUserAuth):
@pytest.fixture(scope="class")
def dbt_profile_target(self):
return {
"type": "redshift",
"method": RedshiftConnectionMethod.IAM.value,
"iam_profile": os.getenv("REDSHIFT_TEST_IAM_USER_PROFILE"),
"cluster_id": os.getenv("REDSHIFT_TEST_CLUSTER_ID"),
"dbname": os.getenv("REDSHIFT_TEST_DBNAME"),
"user": os.getenv("REDSHIFT_TEST_USER"),
"pass": "",
"host": "",
"port": 0,
"threads": 1,
"retries": 6,
}


class TestIAMUserAuthDirect(IAMUserAuth):
@pytest.fixture(scope="class")
def dbt_profile_target(self):
return {
"type": "redshift",
"method": RedshiftConnectionMethod.IAM.value,
"iam_profile": "",
"access_key_id": os.getenv("REDSHIFT_TEST_IAM_USER_ACCESS_KEY_ID"),
"secret_access_key": os.getenv("REDSHIFT_TEST_IAM_USER_SECRET_ACCESS_KEY"),
"cluster_id": os.getenv("REDSHIFT_TEST_CLUSTER_ID"),
"dbname": os.getenv("REDSHIFT_TEST_DBNAME"),
"user": os.getenv("REDSHIFT_TEST_USER"),
"pass": "",
"host": "",
"port": 0,
"threads": 1,
"retries": 6,
}
Loading
Loading