Skip to content

Commit 0ebf12d

Browse files
[DPE-6345] LDAP I: Create access groups (#823)
1 parent a112917 commit 0ebf12d

File tree

7 files changed

+177
-9
lines changed

7 files changed

+177
-9
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 133 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,12 +620,34 @@ 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+
connection = None
630+
try:
631+
with self._connect_to_database() as connection, connection.cursor() as cursor:
632+
cursor.execute(
633+
"SELECT groname FROM pg_catalog.pg_group WHERE groname LIKE '%_access';"
634+
)
635+
access_groups = cursor.fetchall()
636+
return {group[0] for group in access_groups}
637+
except psycopg2.Error as e:
638+
logger.error(f"Failed to list PostgreSQL database access groups: {e}")
639+
raise PostgreSQLListGroupsError() from e
640+
finally:
641+
if connection is not None:
642+
connection.close()
643+
537644
def list_users(self) -> Set[str]:
538645
"""Returns the list of PostgreSQL database users.
539646
540647
Returns:
541648
List of PostgreSQL database users.
542649
"""
650+
connection = None
543651
try:
544652
with self._connect_to_database() as connection, connection.cursor() as cursor:
545653
cursor.execute("SELECT usename FROM pg_catalog.pg_user;")
@@ -548,6 +656,30 @@ def list_users(self) -> Set[str]:
548656
except psycopg2.Error as e:
549657
logger.error(f"Failed to list PostgreSQL database users: {e}")
550658
raise PostgreSQLListUsersError() from e
659+
finally:
660+
if connection is not None:
661+
connection.close()
662+
663+
def list_users_from_relation(self) -> Set[str]:
664+
"""Returns the list of PostgreSQL database users that were created by a relation.
665+
666+
Returns:
667+
List of PostgreSQL database users.
668+
"""
669+
connection = None
670+
try:
671+
with self._connect_to_database() as connection, connection.cursor() as cursor:
672+
cursor.execute(
673+
"SELECT usename FROM pg_catalog.pg_user WHERE usename LIKE 'relation_id_%';"
674+
)
675+
usernames = cursor.fetchall()
676+
return {username[0] for username in usernames}
677+
except psycopg2.Error as e:
678+
logger.error(f"Failed to list PostgreSQL database users: {e}")
679+
raise PostgreSQLListUsersError() from e
680+
finally:
681+
if connection is not None:
682+
connection.close()
551683

552684
def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
553685
"""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
@@ -23,6 +23,7 @@
2323
from charms.grafana_agent.v0.cos_agent import COSAgentProvider, charm_tracing_config
2424
from charms.operator_libs_linux.v2 import snap
2525
from charms.postgresql_k8s.v0.postgresql import (
26+
ACCESS_GROUPS,
2627
REQUIRED_PLUGINS,
2728
PostgreSQL,
2829
PostgreSQLCreateUserError,
@@ -1360,6 +1361,11 @@ def _start_primary(self, event: StartEvent) -> None:
13601361

13611362
self.postgresql.set_up_database()
13621363

1364+
access_groups = self.postgresql.list_access_groups()
1365+
if access_groups != set(ACCESS_GROUPS):
1366+
self.postgresql.create_access_groups()
1367+
self.postgresql.grant_internal_access_group_memberships()
1368+
13631369
self.postgresql_client_relation.oversee_users()
13641370

13651371
# Set the flag to enable the replicas to start the Patroni service.

src/relations/db.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections.abc import Iterable
88

99
from charms.postgresql_k8s.v0.postgresql import (
10+
ACCESS_GROUP_RELATION,
1011
PostgreSQLCreateDatabaseError,
1112
PostgreSQLCreateUserError,
1213
PostgreSQLGetPostgreSQLVersionError,
@@ -198,10 +199,11 @@ def set_up_relation(self, relation: Relation) -> bool:
198199
# non-leader units when the cluster topology changes.
199200
self.charm.set_secret(APP_SCOPE, user, password)
200201
self.charm.set_secret(APP_SCOPE, f"{user}-database", database)
202+
self.charm.postgresql.create_user(
203+
user, password, self.admin, extra_user_roles=[ACCESS_GROUP_RELATION]
204+
)
201205

202-
self.charm.postgresql.create_user(user, password, self.admin)
203206
plugins = self.charm.get_plugins()
204-
205207
self.charm.postgresql.create_database(
206208
database, user, plugins=plugins, client_relations=self.charm.client_relations
207209
)

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
"""Generate password and handle user and database creation for the related application."""
@@ -93,8 +98,9 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
9398
# Retrieve the database name and extra user roles using the charm library.
9499
database = event.database
95100

96-
# Make sure that certain groups are not in the list
101+
# Make sure the relation access-group is added to the list
97102
extra_user_roles = self._sanitize_extra_roles(event.extra_user_roles)
103+
extra_user_roles.append(ACCESS_GROUP_RELATION)
98104

99105
try:
100106
# 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
UpgradeGrantedEvent,
1414
)
15+
from charms.postgresql_k8s.v0.postgresql import ACCESS_GROUPS
1516
from ops.model import MaintenanceStatus, RelationDataContent, WaitingStatus
1617
from pydantic import BaseModel
1718
from tenacity import RetryError, Retrying, stop_after_attempt, wait_fixed
@@ -247,6 +248,17 @@ def _prepare_upgrade_from_legacy(self) -> None:
247248
extra_user_roles="pg_monitor",
248249
)
249250
self.charm.postgresql.set_up_database()
251+
self._set_up_new_access_roles_for_legacy()
252+
253+
def _set_up_new_access_roles_for_legacy(self) -> None:
254+
"""Create missing access groups and their memberships."""
255+
access_groups = self.charm.postgresql.list_access_groups()
256+
if access_groups == set(ACCESS_GROUPS):
257+
return
258+
259+
self.charm.postgresql.create_access_groups()
260+
self.charm.postgresql.grant_internal_access_group_memberships()
261+
self.charm.postgresql.grant_relation_access_group_memberships()
250262

251263
@property
252264
def unit_upgrade_data(self) -> RelationDataContent:

tests/unit/test_db.py

Lines changed: 10 additions & 3 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,
@@ -226,7 +227,9 @@ def test_set_up_relation(harness):
226227
)
227228
assert harness.charm.legacy_db_relation.set_up_relation(relation)
228229
user = f"relation-{rel_id}"
229-
postgresql_mock.create_user.assert_called_once_with(user, "test-password", False)
230+
postgresql_mock.create_user.assert_called_once_with(
231+
user, "test-password", False, extra_user_roles=[ACCESS_GROUP_RELATION]
232+
)
230233
postgresql_mock.create_database.assert_called_once_with(
231234
DATABASE, user, plugins=["pgaudit"], client_relations=[relation]
232235
)
@@ -253,7 +256,9 @@ def test_set_up_relation(harness):
253256
{"database": DATABASE},
254257
)
255258
assert harness.charm.legacy_db_relation.set_up_relation(relation)
256-
postgresql_mock.create_user.assert_called_once_with(user, "test-password", False)
259+
postgresql_mock.create_user.assert_called_once_with(
260+
user, "test-password", False, extra_user_roles=[ACCESS_GROUP_RELATION]
261+
)
257262
postgresql_mock.create_database.assert_called_once_with(
258263
DATABASE, user, plugins=["pgaudit"], client_relations=[relation]
259264
)
@@ -274,7 +279,9 @@ def test_set_up_relation(harness):
274279
{"database": ""},
275280
)
276281
assert harness.charm.legacy_db_relation.set_up_relation(relation)
277-
postgresql_mock.create_user.assert_called_once_with(user, "test-password", False)
282+
postgresql_mock.create_user.assert_called_once_with(
283+
user, "test-password", False, extra_user_roles=[ACCESS_GROUP_RELATION]
284+
)
278285
postgresql_mock.create_database.assert_called_once_with(
279286
"test_database", user, plugins=["pgaudit"], client_relations=[relation]
280287
)

tests/unit/test_postgresql_provider.py

Lines changed: 4 additions & 1 deletion
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,
@@ -124,10 +125,12 @@ def test_on_database_requested(harness):
124125

125126
# Assert that the correct calls were made.
126127
user = f"relation-{rel_id}"
128+
expected_user_roles = [role.lower() for role in EXTRA_USER_ROLES.split(",")]
129+
expected_user_roles.append(ACCESS_GROUP_RELATION)
127130
postgresql_mock.create_user.assert_called_once_with(
128131
user,
129132
"test-password",
130-
extra_user_roles=[role.lower() for role in EXTRA_USER_ROLES.split(",")],
133+
extra_user_roles=expected_user_roles,
131134
)
132135
database_relation = harness.model.get_relation(RELATION_NAME)
133136
client_relations = [database_relation]

0 commit comments

Comments
 (0)