Skip to content

Commit 96a1117

Browse files
[DPE-6344] LDAP I: Create access groups (#881)
1 parent 804e554 commit 96a1117

File tree

8 files changed

+219
-8
lines changed

8 files changed

+219
-8
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,19 @@
3535

3636
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3737
# to 0 if you are raising the major API version
38-
LIBPATCH = 46
38+
LIBPATCH = 47
39+
40+
# Groups to distinguish HBA access
41+
ACCESS_GROUP_IDENTITY = "identity_access"
42+
ACCESS_GROUP_INTERNAL = "internal_access"
43+
ACCESS_GROUP_RELATION = "relation_access"
44+
45+
# List of access groups to filter role assignments by
46+
ACCESS_GROUPS = [
47+
ACCESS_GROUP_IDENTITY,
48+
ACCESS_GROUP_INTERNAL,
49+
ACCESS_GROUP_RELATION,
50+
]
3951

4052
# Groups to distinguish database permissions
4153
PERMISSIONS_GROUP_ADMIN = "admin"
@@ -57,10 +69,18 @@
5769
logger = logging.getLogger(__name__)
5870

5971

72+
class PostgreSQLAssignGroupError(Exception):
73+
"""Exception raised when assigning to a group fails."""
74+
75+
6076
class PostgreSQLCreateDatabaseError(Exception):
6177
"""Exception raised when creating a database fails."""
6278

6379

80+
class PostgreSQLCreateGroupError(Exception):
81+
"""Exception raised when creating a group fails."""
82+
83+
6484
class PostgreSQLCreateUserError(Exception):
6585
"""Exception raised when creating a user fails."""
6686

@@ -93,6 +113,10 @@ class PostgreSQLGetPostgreSQLVersionError(Exception):
93113
"""Exception raised when retrieving PostgreSQL version fails."""
94114

95115

116+
class PostgreSQLListGroupsError(Exception):
117+
"""Exception raised when retrieving PostgreSQL groups list fails."""
118+
119+
96120
class PostgreSQLListUsersError(Exception):
97121
"""Exception raised when retrieving PostgreSQL users list fails."""
98122

@@ -160,6 +184,24 @@ def _connect_to_database(
160184
connection.autocommit = True
161185
return connection
162186

187+
def create_access_groups(self) -> None:
188+
"""Create access groups to distinguish HBA authentication methods."""
189+
connection = None
190+
try:
191+
with self._connect_to_database() as connection, connection.cursor() as cursor:
192+
for group in ACCESS_GROUPS:
193+
cursor.execute(
194+
SQL("CREATE ROLE {} NOLOGIN;").format(
195+
Identifier(group),
196+
)
197+
)
198+
except psycopg2.Error as e:
199+
logger.error(f"Failed to create access groups: {e}")
200+
raise PostgreSQLCreateGroupError() from e
201+
finally:
202+
if connection is not None:
203+
connection.close()
204+
163205
def create_database(
164206
self,
165207
database: str,
@@ -321,6 +363,50 @@ def delete_user(self, user: str) -> None:
321363
logger.error(f"Failed to delete user: {e}")
322364
raise PostgreSQLDeleteUserError() from e
323365

366+
def grant_internal_access_group_memberships(self) -> None:
367+
"""Grant membership to the internal access-group to existing internal users."""
368+
connection = None
369+
try:
370+
with self._connect_to_database() as connection, connection.cursor() as cursor:
371+
for user in self.system_users:
372+
cursor.execute(
373+
SQL("GRANT {} TO {};").format(
374+
Identifier(ACCESS_GROUP_INTERNAL),
375+
Identifier(user),
376+
)
377+
)
378+
except psycopg2.Error as e:
379+
logger.error(f"Failed to grant internal access group memberships: {e}")
380+
raise PostgreSQLAssignGroupError() from e
381+
finally:
382+
if connection is not None:
383+
connection.close()
384+
385+
def grant_relation_access_group_memberships(self) -> None:
386+
"""Grant membership to the relation access-group to existing relation users."""
387+
rel_users = self.list_users_from_relation()
388+
if not rel_users:
389+
return
390+
391+
connection = None
392+
try:
393+
with self._connect_to_database() as connection, connection.cursor() as cursor:
394+
rel_groups = SQL(",").join(Identifier(group) for group in [ACCESS_GROUP_RELATION])
395+
rel_users = SQL(",").join(Identifier(user) for user in rel_users)
396+
397+
cursor.execute(
398+
SQL("GRANT {groups} TO {users};").format(
399+
groups=rel_groups,
400+
users=rel_users,
401+
)
402+
)
403+
except psycopg2.Error as e:
404+
logger.error(f"Failed to grant relation access group memberships: {e}")
405+
raise PostgreSQLAssignGroupError() from e
406+
finally:
407+
if connection is not None:
408+
connection.close()
409+
324410
def enable_disable_extensions(
325411
self, extensions: Dict[str, bool], database: Optional[str] = None
326412
) -> None:
@@ -534,6 +620,26 @@ def is_tls_enabled(self, check_current_host: bool = False) -> bool:
534620
# Connection errors happen when PostgreSQL has not started yet.
535621
return False
536622

623+
def list_access_groups(self) -> Set[str]:
624+
"""Returns the list of PostgreSQL database access groups.
625+
626+
Returns:
627+
List of PostgreSQL database access groups.
628+
"""
629+
try:
630+
with self._connect_to_database() as connection, connection.cursor() as cursor:
631+
cursor.execute(
632+
"SELECT groname FROM pg_catalog.pg_group WHERE groname LIKE '%_access';"
633+
)
634+
access_groups = cursor.fetchall()
635+
return {group[0] for group in access_groups}
636+
except psycopg2.Error as e:
637+
logger.error(f"Failed to list PostgreSQL database access groups: {e}")
638+
raise PostgreSQLListGroupsError() from e
639+
finally:
640+
if connection is not None:
641+
connection.close()
642+
537643
def list_users(self) -> Set[str]:
538644
"""Returns the list of PostgreSQL database users.
539645
@@ -548,6 +654,29 @@ def list_users(self) -> Set[str]:
548654
except psycopg2.Error as e:
549655
logger.error(f"Failed to list PostgreSQL database users: {e}")
550656
raise PostgreSQLListUsersError() from e
657+
finally:
658+
if connection is not None:
659+
connection.close()
660+
661+
def list_users_from_relation(self) -> Set[str]:
662+
"""Returns the list of PostgreSQL database users that were created by a relation.
663+
664+
Returns:
665+
List of PostgreSQL database users.
666+
"""
667+
try:
668+
with self._connect_to_database() as connection, connection.cursor() as cursor:
669+
cursor.execute(
670+
"SELECT usename FROM pg_catalog.pg_user WHERE usename LIKE 'relation_id_%';"
671+
)
672+
usernames = cursor.fetchall()
673+
return {username[0] for username in usernames}
674+
except psycopg2.Error as e:
675+
logger.error(f"Failed to list PostgreSQL database users: {e}")
676+
raise PostgreSQLListUsersError() from e
677+
finally:
678+
if connection is not None:
679+
connection.close()
551680

552681
def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
553682
"""Returns two sets with valid privileges and roles.

src/charm.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
3636
from charms.loki_k8s.v1.loki_push_api import LogProxyConsumer
3737
from charms.postgresql_k8s.v0.postgresql import (
38+
ACCESS_GROUPS,
3839
REQUIRED_PLUGINS,
3940
PostgreSQL,
4041
PostgreSQLEnableDisableExtensionError,
@@ -1098,6 +1099,11 @@ def _initialize_cluster(self, event: WorkloadEvent) -> bool:
10981099

10991100
self.postgresql.set_up_database()
11001101

1102+
access_groups = self.postgresql.list_access_groups()
1103+
if access_groups != set(ACCESS_GROUPS):
1104+
self.postgresql.create_access_groups()
1105+
self.postgresql.grant_internal_access_group_memberships()
1106+
11011107
# Mark the cluster as initialised.
11021108
self._peers.data[self.app]["cluster_initialised"] = "True"
11031109

src/relations/db.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Iterable
88

99
from charms.postgresql_k8s.v0.postgresql import (
10+
ACCESS_GROUP_RELATION,
1011
PostgreSQLCreateDatabaseError,
1112
PostgreSQLCreateUserError,
1213
PostgreSQLDeleteUserError,
@@ -173,9 +174,11 @@ def set_up_relation(self, relation: Relation) -> bool:
173174
# created in a previous relation changed event.
174175
user = f"relation_id_{relation.id}"
175176
password = unit_relation_databag.get("password", new_password())
176-
self.charm.postgresql.create_user(user, password, self.admin)
177-
plugins = self.charm.get_plugins()
177+
self.charm.postgresql.create_user(
178+
user, password, self.admin, extra_user_roles=[ACCESS_GROUP_RELATION]
179+
)
178180

181+
plugins = self.charm.get_plugins()
179182
self.charm.postgresql.create_database(
180183
database, user, plugins=plugins, client_relations=self.charm.client_relations
181184
)

src/relations/postgresql_provider.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
DatabaseRequestedEvent,
1111
)
1212
from charms.postgresql_k8s.v0.postgresql import (
13+
ACCESS_GROUP_RELATION,
14+
ACCESS_GROUPS,
1315
INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE,
1416
PostgreSQLCreateDatabaseError,
1517
PostgreSQLCreateUserError,
@@ -71,7 +73,10 @@ def _sanitize_extra_roles(extra_roles: str | None) -> list[str]:
7173
if extra_roles is None:
7274
return []
7375

74-
return [role.lower() for role in extra_roles.split(",")]
76+
# Make sure the access-groups are not in the list
77+
extra_roles_list = [role.lower() for role in extra_roles.split(",")]
78+
extra_roles_list = [role for role in extra_roles_list if role not in ACCESS_GROUPS]
79+
return extra_roles_list
7580

7681
def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
7782
"""Handle the legacy postgresql-client relation changed event.
@@ -89,8 +94,9 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
8994
# Retrieve the database name and extra user roles using the charm library.
9095
database = event.database
9196

92-
# Make sure that certain groups are not in the list
97+
# Make sure the relation access-group is added to the list
9398
extra_user_roles = self._sanitize_extra_roles(event.extra_user_roles)
99+
extra_user_roles.append(ACCESS_GROUP_RELATION)
94100

95101
try:
96102
# Creates the user and the database for this specific relation.

src/upgrade.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DependencyModel,
1313
KubernetesClientError,
1414
)
15+
from charms.postgresql_k8s.v0.postgresql import ACCESS_GROUPS
1516
from lightkube.core.client import Client
1617
from lightkube.core.exceptions import ApiError
1718
from lightkube.resources.apps_v1 import StatefulSet
@@ -121,6 +122,7 @@ def _on_postgresql_pebble_ready(self, event: WorkloadEvent) -> None:
121122
event.defer()
122123
return
123124
self._set_up_new_credentials_for_legacy()
125+
self._set_up_new_access_roles_for_legacy()
124126

125127
try:
126128
for attempt in Retrying(stop=stop_after_attempt(6), wait=wait_fixed(10)):
@@ -270,6 +272,16 @@ def _set_first_rolling_update_partition(self) -> None:
270272
except KubernetesClientError as e:
271273
raise ClusterNotReadyError(e.message, e.cause) from e
272274

275+
def _set_up_new_access_roles_for_legacy(self) -> None:
276+
"""Create missing access groups and their memberships."""
277+
access_groups = self.charm.postgresql.list_access_groups()
278+
if access_groups == set(ACCESS_GROUPS):
279+
return
280+
281+
self.charm.postgresql.create_access_groups()
282+
self.charm.postgresql.grant_internal_access_group_memberships()
283+
self.charm.postgresql.grant_relation_access_group_memberships()
284+
273285
def _set_up_new_credentials_for_legacy(self) -> None:
274286
"""Create missing password and user."""
275287
for key in (MONITORING_PASSWORD_KEY, PATRONI_PASSWORD_KEY):

tests/unit/test_db.py

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

66
import pytest
77
from charms.postgresql_k8s.v0.postgresql import (
8+
ACCESS_GROUP_RELATION,
89
PostgreSQLCreateDatabaseError,
910
PostgreSQLCreateUserError,
1011
PostgreSQLGetPostgreSQLVersionError,
@@ -230,7 +231,9 @@ def test_set_up_relation(harness):
230231
)
231232
assert harness.charm.legacy_db_relation.set_up_relation(relation)
232233
user = f"relation_id_{rel_id}"
233-
postgresql_mock.create_user.assert_called_once_with(user, "test-password", False)
234+
postgresql_mock.create_user.assert_called_once_with(
235+
user, "test-password", False, extra_user_roles=[ACCESS_GROUP_RELATION]
236+
)
234237
postgresql_mock.create_database.assert_called_once_with(
235238
DATABASE, user, plugins=["pgaudit"], client_relations=[relation]
236239
)
@@ -275,7 +278,9 @@ def test_set_up_relation(harness):
275278
)
276279
clear_relation_data(harness)
277280
assert harness.charm.legacy_db_relation.set_up_relation(relation)
278-
postgresql_mock.create_user.assert_called_once_with(user, "test-password", False)
281+
postgresql_mock.create_user.assert_called_once_with(
282+
user, "test-password", False, extra_user_roles=[ACCESS_GROUP_RELATION]
283+
)
279284
postgresql_mock.create_database.assert_called_once_with(
280285
DATABASE, user, plugins=["pgaudit"], client_relations=[relation]
281286
)

tests/unit/test_postgresql.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import psycopg2
66
import pytest
77
from charms.postgresql_k8s.v0.postgresql import (
8+
ACCESS_GROUP_INTERNAL,
9+
ACCESS_GROUPS,
810
PERMISSIONS_GROUP_ADMIN,
911
PostgreSQLCreateDatabaseError,
1012
PostgreSQLGetLastArchivedWALError,
@@ -19,6 +21,7 @@
1921
PEER,
2022
REPLICATION_USER,
2123
REWIND_USER,
24+
SYSTEM_USERS,
2225
USER,
2326
)
2427

@@ -35,6 +38,21 @@ def harness():
3538
harness.cleanup()
3639

3740

41+
def test_create_access_groups(harness):
42+
with patch(
43+
"charms.postgresql_k8s.v0.postgresql.PostgreSQL._connect_to_database"
44+
) as _connect_to_database:
45+
execute = _connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.execute
46+
harness.charm.postgresql.create_access_groups()
47+
48+
execute.assert_has_calls([
49+
*(
50+
call(SQL("CREATE ROLE {} NOLOGIN;").format(Identifier(group)))
51+
for group in ACCESS_GROUPS
52+
),
53+
])
54+
55+
3856
def test_create_database(harness):
3957
with (
4058
patch(
@@ -165,6 +183,35 @@ def test_create_database(harness):
165183
_enable_disable_extensions.assert_not_called()
166184

167185

186+
def test_grant_internal_access_group_memberships(harness):
187+
with patch(
188+
"charms.postgresql_k8s.v0.postgresql.PostgreSQL._connect_to_database"
189+
) as _connect_to_database:
190+
execute = _connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.execute
191+
harness.charm.postgresql.grant_internal_access_group_memberships()
192+
193+
internal_group = Identifier(ACCESS_GROUP_INTERNAL)
194+
195+
execute.assert_has_calls([
196+
*(
197+
call(SQL("GRANT {} TO {};").format(internal_group, Identifier(user)))
198+
for user in SYSTEM_USERS
199+
),
200+
])
201+
202+
203+
def test_grant_relation_access_group_memberships(harness):
204+
with patch(
205+
"charms.postgresql_k8s.v0.postgresql.PostgreSQL._connect_to_database"
206+
) as _connect_to_database:
207+
execute = _connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.execute
208+
harness.charm.postgresql.grant_relation_access_group_memberships()
209+
210+
execute.assert_has_calls([
211+
call("SELECT usename FROM pg_catalog.pg_user WHERE usename LIKE 'relation_id_%';"),
212+
])
213+
214+
168215
def test_generate_database_privileges_statements(harness):
169216
# Test with only one established relation.
170217
assert harness.charm.postgresql._generate_database_privileges_statements(

0 commit comments

Comments
 (0)