Skip to content

Commit d1b97d6

Browse files
feat: support syncing service accounts with GroupSync (#73)
1 parent b177167 commit d1b97d6

File tree

6 files changed

+101
-25
lines changed

6 files changed

+101
-25
lines changed

iam_groups_authn/postgres.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@
2222
from google.auth.transport.requests import Request
2323

2424

25+
def postgres_username(iam_email):
26+
"""Get Postgres username from user or service account email.
27+
28+
Given an IAM user or IAM service account email, format their Postgres
29+
database username accordingly. Do nothing for user emails, remove
30+
'.gserviceaccount.com' suffix from service account emails.
31+
32+
Args:
33+
iam_email: An IAM user or service account email.
34+
35+
Returns:
36+
username: The IAM user or service account Postgres DB username.
37+
"""
38+
username = iam_email.removesuffix(".gserviceaccount.com")
39+
return username
40+
41+
2542
class PostgresRoleService(RoleService):
2643
"""Class for managing a Postgres DB user's role grants."""
2744

iam_groups_authn/sql_admin.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from typing import NamedTuple
1818
from iam_groups_authn.mysql import mysql_username
19+
from iam_groups_authn.postgres import postgres_username
1920

2021

2122
class InstanceConnectionName(NamedTuple):
@@ -76,10 +77,14 @@ async def add_missing_db_users(
7677
[user for user in iam_users if mysql_username(user) not in db_users]
7778
)
7879
else:
79-
missing_db_users = set([user for user in iam_users if user not in db_users])
80+
missing_db_users = set(
81+
[user for user in iam_users if postgres_username(user) not in db_users]
82+
)
8083
# add missing users to database instance
8184
for user in missing_db_users:
8285
await user_service.insert_db_user(
83-
user, InstanceConnectionName(*instance_connection_name.split(":"))
86+
user,
87+
InstanceConnectionName(*instance_connection_name.split(":")),
88+
database_type,
8489
)
8590
return missing_db_users

iam_groups_authn/sync.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from iam_groups_authn.postgres import (
3838
init_postgres_connection_engine,
3939
PostgresRoleService,
40+
postgres_username,
4041
)
4142

4243

@@ -298,7 +299,7 @@ async def get_db_users(self, instance_connection_name):
298299
f"Error: Failed to get the database users for instance `{instance_connection_name}`. Verify instance connection name and instance details."
299300
) from e
300301

301-
async def insert_db_user(self, user_email, instance_connection_name):
302+
async def insert_db_user(self, user_email, instance_connection_name, database_type):
302303
"""Create DB user from IAM user.
303304
304305
Given an IAM user's email, insert the IAM user as a DB user for Cloud SQL instance.
@@ -308,12 +309,27 @@ async def insert_db_user(self, user_email, instance_connection_name):
308309
instance_connection_name: InstanceConnectionName namedTuple.
309310
(e.g. InstanceConnectionName(project='my-project', region='my-region',
310311
instance='my-instance'))
312+
database_type: Cloud SQL database version.
311313
"""
312314
# build request to SQL Admin API
313315
project = instance_connection_name.project
314316
instance = instance_connection_name.instance
315317
url = f"https://sqladmin.googleapis.com/sql/v1beta4/projects/{project}/instances/{instance}/users"
316-
user = {"name": user_email, "type": "CLOUD_IAM_USER"}
318+
# if service account, add service account IAM database user
319+
if user_email.endswith(".gserviceaccount.com"):
320+
# the Cloud SQL Admin API doesn't format Postgres usernames, but does format MySQL usernames
321+
if database_type.is_mysql():
322+
user = {
323+
"name": user_email,
324+
"type": "CLOUD_IAM_SERVICE_ACCOUNT",
325+
}
326+
else:
327+
user = {
328+
"name": user_email.removesuffix(".gserviceaccount.com"),
329+
"type": "CLOUD_IAM_SERVICE_ACCOUNT",
330+
}
331+
else:
332+
user = {"name": user_email, "type": "CLOUD_IAM_USER"}
317333

318334
try:
319335
# call the SQL Admin API
@@ -483,6 +499,9 @@ async def grant_iam_group_role(
483499
if database_type.is_mysql():
484500
# truncate mysql_usernames
485501
iam_users = [mysql_username(user) for user in iam_users]
502+
elif database_type.is_postgres():
503+
# truncate postgres service accounts
504+
iam_users = [postgres_username(user) for user in iam_users]
486505

487506
# find DB users who are part of IAM group that need role granted to them
488507
users_to_grant = [user for user in iam_users if user not in users_with_roles]

tests/integration/test_mysql_sync.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
sql_instance = os.environ["MYSQL_INSTANCE"]
3030
iam_groups = [os.environ["IAM_GROUPS"]]
3131
test_user = os.environ["TEST_USER"]
32+
sa_user = os.environ["SA_USER"]
3233

3334
scopes = [
3435
"https://www.googleapis.com/auth/admin.directory.group.member",
@@ -73,8 +74,12 @@ def setup_and_teardown():
7374
try:
7475
# cleanup user from database
7576
delete_database_user(sql_instance, mysql_username(test_user), credentials)
77+
# cleanup service account from database
78+
delete_database_user(sql_instance, mysql_username(sa_user), credentials)
7679
# re-add member to IAM group
7780
add_iam_member(iam_groups[0], test_user, credentials)
81+
# re-add service account to IAM group
82+
add_iam_member(iam_groups[0], sa_user, credentials)
7883
# wait 30 seconds, adding IAM member is slow
7984
time.sleep(30)
8085
except Exception:
@@ -95,11 +100,12 @@ async def test_service_mysql(credentials):
95100
- Verifies test user no longer has group role
96101
"""
97102

98-
# remove database user if they exist
103+
# remove database users if they exist
99104
try:
100105
delete_database_user(sql_instance, mysql_username(test_user), credentials)
106+
delete_database_user(sql_instance, mysql_username(sa_user), credentials)
101107
except Exception:
102-
print("Database user must already have been deleted!")
108+
print("Database users must already have been deleted!")
103109

104110
# create aiohttp client session for async API calls
105111
client_session = ClientSession(headers={"Content-Type": "application/json"})
@@ -108,21 +114,24 @@ async def test_service_mysql(credentials):
108114
user_service = UserService(client_session, credentials)
109115
db_users = await get_instance_users(user_service, sql_instance)
110116
assert mysql_username(test_user) not in db_users
117+
assert mysql_username(sa_user) not in db_users
111118

112-
# make sure test_user is member of IAM group
119+
# make sure users are members of IAM group
113120
try:
114121
add_iam_member(iam_groups[0], test_user, credentials)
122+
add_iam_member(iam_groups[0], sa_user, credentials)
115123
# wait 30 seconds, adding IAM member is slow
116124
time.sleep(30)
117125
except Exception:
118-
print("Member must already belong to IAM Group.")
126+
print("Members must already belong to IAM Group.")
119127

120128
# run groups sync
121129
await groups_sync(iam_groups, [sql_instance], credentials, dict(), False)
122130

123131
# check that test_user has been created as database user
124132
db_users = await get_instance_users(user_service, sql_instance)
125133
assert mysql_username(test_user) in db_users
134+
assert mysql_username(sa_user) in db_users
126135

127136
# create database connection to instance
128137
pool = init_mysql_connection_engine(sql_instance, credentials)
@@ -134,8 +143,9 @@ async def test_service_mysql(credentials):
134143
for member in iam_members:
135144
assert mysql_username(member) in users_with_role
136145

137-
# remove test_user from IAM group
146+
# remove users from IAM group
138147
delete_iam_member(iam_groups[0], test_user, credentials)
148+
delete_iam_member(iam_groups[0], sa_user, credentials)
139149

140150
# wait 30 seconds, deleting IAM member is slow
141151
time.sleep(30)
@@ -146,6 +156,7 @@ async def test_service_mysql(credentials):
146156
# verify test_user no longer has group role
147157
users_with_role = check_role_mysql(pool, mysql_username(iam_groups[0]))
148158
assert mysql_username(test_user) not in users_with_role
159+
assert mysql_username(sa_user) not in users_with_role
149160

150161
# close aiohttp client session for graceful exit
151162
if not client_session.closed:

tests/integration/test_postgres_sync.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from helpers import delete_database_user, delete_iam_member, add_iam_member
2222
from iam_groups_authn.iam_admin import get_iam_users
2323
from iam_groups_authn.mysql import mysql_username
24-
from iam_groups_authn.postgres import init_postgres_connection_engine
24+
from iam_groups_authn.postgres import init_postgres_connection_engine, postgres_username
2525
from iam_groups_authn.sql_admin import get_instance_users
2626
from iam_groups_authn.sync import groups_sync, UserService
2727
import time
@@ -30,6 +30,7 @@
3030
sql_instance = os.environ["POSTGRES_INSTANCE"]
3131
iam_groups = [os.environ["IAM_GROUPS"]]
3232
test_user = os.environ["TEST_USER"]
33+
sa_user = os.environ["SA_USER"]
3334

3435
scopes = [
3536
"https://www.googleapis.com/auth/admin.directory.group.member",
@@ -74,8 +75,12 @@ def setup_and_teardown():
7475
try:
7576
# cleanup user from database
7677
delete_database_user(sql_instance, test_user, credentials)
78+
# cleanup service account from database
79+
delete_database_user(sql_instance, postgres_username(sa_user), credentials)
7780
# re-add member to IAM group
7881
add_iam_member(iam_groups[0], test_user, credentials)
82+
# re-add service account to IAM group
83+
add_iam_member(iam_groups[0], sa_user, credentials)
7984
# wait 30 seconds, adding IAM member is slow
8085
time.sleep(30)
8186
except Exception:
@@ -96,23 +101,26 @@ async def test_service_postgres(credentials):
96101
- Verifies test user no longer has group role
97102
"""
98103

99-
# remove database user if they exist
104+
# remove database users if they exist
100105
try:
101106
delete_database_user(sql_instance, test_user, credentials)
107+
delete_database_user(sql_instance, postgres_username(sa_user), credentials)
102108
except Exception:
103-
print("Database user must already have been deleted!")
109+
print("Database users must already have been deleted!")
104110

105111
# create aiohttp client session for async API calls
106112
client_session = ClientSession(headers={"Content-Type": "application/json"})
107113

108-
# check that test_user is not a database user
114+
# check that users are not a database user
109115
user_service = UserService(client_session, credentials)
110116
db_users = await get_instance_users(user_service, sql_instance)
111117
assert test_user not in db_users
118+
assert postgres_username(sa_user) not in db_users
112119

113-
# make sure test_user is member of IAM group
120+
# make sure users are members of IAM group
114121
try:
115122
add_iam_member(iam_groups[0], test_user, credentials)
123+
add_iam_member(iam_groups[0], sa_user, credentials)
116124
# wait 30 seconds, adding IAM member is slow
117125
time.sleep(30)
118126
except Exception:
@@ -121,9 +129,10 @@ async def test_service_postgres(credentials):
121129
# run groups sync
122130
await groups_sync(iam_groups, [sql_instance], credentials, dict(), False)
123131

124-
# check that test_user has been created as database user
132+
# check that users has been created as database users
125133
db_users = await get_instance_users(user_service, sql_instance)
126134
assert test_user in db_users
135+
assert postgres_username(sa_user) in db_users
127136

128137
# create database connection to instance
129138
pool = init_postgres_connection_engine(sql_instance, credentials)
@@ -133,10 +142,11 @@ async def test_service_postgres(credentials):
133142
users_with_role = check_role_postgres(pool, mysql_username(iam_group))
134143
iam_members = await get_iam_users(user_service, iam_group)
135144
for member in iam_members:
136-
assert member in users_with_role
145+
assert postgres_username(member) in users_with_role
137146

138-
# remove test_user from IAM group
147+
# remove users from IAM group
139148
delete_iam_member(iam_groups[0], test_user, credentials)
149+
delete_iam_member(iam_groups[0], sa_user, credentials)
140150

141151
# wait 30 seconds, deleting IAM member is slow
142152
time.sleep(30)
@@ -147,6 +157,7 @@ async def test_service_postgres(credentials):
147157
# verify test_user no longer has group role
148158
users_with_role = check_role_postgres(pool, mysql_username(iam_groups[0]))
149159
assert test_user not in users_with_role
160+
assert postgres_username(sa_user) not in users_with_role
150161

151162
# close aiohttp client session for graceful exit
152163
if not client_session.closed:

tests/unit/test_add_missing_db_users.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class FakeUserService:
2424
def __init__(self):
2525
pass
2626

27-
async def insert_db_user(self, user, instance_connection_name):
27+
async def insert_db_user(self, user, instance_connection_name, database_type):
2828
pass
2929

3030

@@ -35,11 +35,13 @@ async def test_no_missing_users():
3535
"""
3636
user_service = FakeUserService()
3737
iam_future = asyncio.Future()
38-
iam_future.set_result(["user1@test.com", "user2@test.com"])
38+
iam_future.set_result(
39+
["user1@test.com", "user2@test.com", "sa@test.iam.gserviceaccount.com"]
40+
)
3941
mysql_users = asyncio.Future()
40-
mysql_users.set_result(["user1", "user2"])
42+
mysql_users.set_result(["user1", "user2", "sa"])
4143
postgres_users = asyncio.Future()
42-
postgres_users.set_result(["user1@test.com", "user2@test.com"])
44+
postgres_users.set_result(["user1@test.com", "user2@test.com", "sa@test.iam"])
4345

4446
missing_mysql_users = await add_missing_db_users(
4547
user_service,
@@ -60,13 +62,20 @@ async def test_no_missing_users():
6062

6163

6264
@pytest.mark.asyncio
63-
async def test_missing__users():
65+
async def test_missing_users():
6466
"""Test where there are IAM users missing corresponding database user.
6567
Should return set of the emails of IAM users missing database user.
6668
"""
6769
user_service = FakeUserService()
6870
iam_future = asyncio.Future()
69-
iam_future.set_result(["user1@test.com", "user2@test.com", "user3@test.com"])
71+
iam_future.set_result(
72+
[
73+
"user1@test.com",
74+
"user2@test.com",
75+
"user3@test.com",
76+
"sa@test.iam.gserviceaccount.com",
77+
]
78+
)
7079
postgres_users = asyncio.Future()
7180
postgres_users.set_result(["user1@test.com"])
7281
mysql_users = asyncio.Future()
@@ -79,7 +88,9 @@ async def test_missing__users():
7988
"group:region:instance",
8089
DatabaseVersion.POSTGRES_13,
8190
)
82-
assert missing_iam_users == set(["user2@test.com", "user3@test.com"])
91+
assert missing_iam_users == set(
92+
["user2@test.com", "user3@test.com", "sa@test.iam.gserviceaccount.com"]
93+
)
8394

8495
missing_iam_users = await add_missing_db_users(
8596
user_service,
@@ -88,7 +99,9 @@ async def test_missing__users():
8899
"group:region:instance",
89100
DatabaseVersion.MYSQL_8_0,
90101
)
91-
assert missing_iam_users == set(["user2@test.com", "user3@test.com"])
102+
assert missing_iam_users == set(
103+
["user2@test.com", "user3@test.com", "sa@test.iam.gserviceaccount.com"]
104+
)
92105

93106

94107
@pytest.mark.asyncio

0 commit comments

Comments
 (0)