Skip to content

Commit 03b7e54

Browse files
[DPE-7498] Create DBA role (#932)
* Implement instance level predefined roles * Fix minor bug introduced while rebasing off of 16/edge * Add integration test for charmed_read and charmed_dml roles * Revert all major changes except introduction of predefined roles * Sweep diff and minor bug fixes * Avoid creating set_user extension * Port Carl's fix for broken unit tests * Create DBA role Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Bump postgresql charm lib for 16/edge to v1 due to backwards incompatible changes * Add DBA user test Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Test DBA role in replica Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Grant reset_user function to DBA role Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Test set_user function for unprivileged users Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Re-add mistakenly removed patch statements * Reset connection to None before creating a new connection Signed-off-by: Marcelo Henrique Neppel <[email protected]> --------- Signed-off-by: Marcelo Henrique Neppel <[email protected]> Co-authored-by: Shayan Patel <[email protected]>
1 parent 6f8e54e commit 03b7e54

File tree

6 files changed

+150
-3
lines changed

6 files changed

+150
-3
lines changed

lib/charms/postgresql_k8s/v1/postgresql.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
ROLE_READ = "charmed_read"
5454
ROLE_DML = "charmed_dml"
5555
ROLE_BACKUP = "charmed_backup"
56+
ROLE_DBA = "charmed_dba"
5657

5758
# Groups to distinguish database permissions
5859
PERMISSIONS_GROUP_ADMIN = "admin"
@@ -341,6 +342,17 @@ def create_user(
341342

342343
def create_predefined_roles(self) -> None:
343344
"""Create predefined roles."""
345+
connection = None
346+
try:
347+
for database in ["postgres", "template1"]:
348+
with self._connect_to_database(
349+
database=database,
350+
) as connection, connection.cursor() as cursor:
351+
cursor.execute(SQL("CREATE EXTENSION IF NOT EXISTS set_user;"))
352+
finally:
353+
if connection is not None:
354+
connection.close()
355+
connection = None
344356
role_to_queries = {
345357
ROLE_STATS: [
346358
f"CREATE ROLE {ROLE_STATS} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_monitor",
@@ -359,6 +371,14 @@ def create_predefined_roles(self) -> None:
359371
f"GRANT execute ON FUNCTION pg_create_restore_point TO {ROLE_BACKUP}",
360372
f"GRANT execute ON FUNCTION pg_switch_wal TO {ROLE_BACKUP}",
361373
],
374+
ROLE_DBA: [
375+
f"CREATE ROLE {ROLE_DBA} NOSUPERUSER CREATEDB NOCREATEROLE NOLOGIN NOREPLICATION;",
376+
f"GRANT execute ON FUNCTION set_user(text) TO {ROLE_DBA};",
377+
f"GRANT execute ON FUNCTION set_user(text, text) TO {ROLE_DBA};",
378+
f"GRANT execute ON FUNCTION set_user_u(text) TO {ROLE_DBA};"
379+
f"GRANT execute ON FUNCTION reset_user() TO {ROLE_DBA};"
380+
f"GRANT execute ON FUNCTION reset_user(text) TO {ROLE_DBA};"
381+
]
362382
}
363383

364384
_, existing_roles = self.list_valid_privileges_and_roles()
@@ -377,6 +397,9 @@ def create_predefined_roles(self) -> None:
377397
except psycopg2.Error as e:
378398
logger.error(f"Failed to create predefined roles: {e}")
379399
raise PostgreSQLCreatePredefinedRolesError() from e
400+
finally:
401+
if connection is not None:
402+
connection.close()
380403

381404
def grant_database_privileges_to_user(
382405
self, user: str, database: str, privileges: list[str]

templates/patroni.yml.j2

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ bootstrap:
9696
log_truncate_on_rotation: 'on'
9797
logging_collector: 'on'
9898
wal_level: logical
99-
shared_preload_libraries: 'timescaledb,pgaudit'
99+
shared_preload_libraries: 'timescaledb,pgaudit,set_user'
100+
set_user.block_log_statement: 'on'
101+
set_user.exit_on_error: 'on'
102+
set_user.superuser_allowlist: '+charmed_dba'
100103

101104
{%- if restoring_backup %}
102105
method: pgbackrest
@@ -132,7 +135,10 @@ postgresql:
132135
bin_dir: /snap/charmed-postgresql/current/usr/lib/postgresql/{{ version }}/bin
133136
data_dir: {{ data_path }}
134137
parameters:
135-
shared_preload_libraries: 'timescaledb,pgaudit'
138+
shared_preload_libraries: 'timescaledb,pgaudit,set_user'
139+
set_user.block_log_statement: 'on'
140+
set_user.exit_on_error: 'on'
141+
set_user.superuser_allowlist: '+charmed_dba'
136142
{%- if enable_pgbackrest_archiving %}
137143
archive_command: 'pgbackrest {{ pgbackrest_configuration_file }} --stanza={{ stanza }} archive-push %p'
138144
{% else %}

tests/integration/helpers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
DATABASE_APP_NAME = METADATA["name"]
3838
STORAGE_PATH = METADATA["storage"]["data"]["location"]
3939
APPLICATION_NAME = "postgresql-test-app"
40+
DATA_INTEGRATOR_APP_NAME = "data-integrator"
4041

4142

4243
class SecretNotFoundError(Exception):
@@ -737,6 +738,23 @@ def get_unit_address(ops_test: OpsTest, unit_name: str, model: Model = None) ->
737738
return model.units.get(unit_name).public_address
738739

739740

741+
def check_connected_user(
742+
cursor, session_user: str, current_user: str, primary: bool = True
743+
) -> None:
744+
cursor.execute("SELECT session_user,current_user;")
745+
result = cursor.fetchone()
746+
if result is not None:
747+
instance = "primary" if primary else "replica"
748+
assert result[0] == session_user, (
749+
f"The session user should be the {session_user} user in the {instance}"
750+
)
751+
assert result[1] == current_user, (
752+
f"The current user should be the {current_user} user in the {instance}"
753+
)
754+
else:
755+
assert False, "No result returned from the query"
756+
757+
740758
async def check_tls(ops_test: OpsTest, unit_name: str, enabled: bool) -> bool:
741759
"""Returns whether TLS is enabled on the specific PostgreSQL instance.
742760
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
import asyncio
6+
import logging
7+
8+
import psycopg2
9+
import psycopg2.sql
10+
import pytest
11+
from pytest_operator.plugin import OpsTest
12+
13+
from .helpers import (
14+
CHARM_BASE,
15+
DATA_INTEGRATOR_APP_NAME,
16+
DATABASE_APP_NAME,
17+
check_connected_user,
18+
)
19+
from .new_relations.helpers import build_connection_string
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
@pytest.mark.abort_on_fail
25+
async def test_deploy(ops_test: OpsTest, charm: str):
26+
"""Deploy the postgresql charm along with data integrator charm."""
27+
async with ops_test.fast_forward("10s"):
28+
await asyncio.gather(
29+
ops_test.model.deploy(
30+
charm,
31+
application_name=DATABASE_APP_NAME,
32+
num_units=2,
33+
base=CHARM_BASE,
34+
config={"profile": "testing"},
35+
),
36+
ops_test.model.deploy(
37+
DATA_INTEGRATOR_APP_NAME,
38+
base=CHARM_BASE,
39+
),
40+
)
41+
42+
await ops_test.model.wait_for_idle(apps=[DATABASE_APP_NAME], status="active")
43+
assert ops_test.model.applications[DATABASE_APP_NAME].units[0].workload_status == "active"
44+
await ops_test.model.wait_for_idle(apps=[DATA_INTEGRATOR_APP_NAME], status="blocked")
45+
46+
47+
@pytest.mark.abort_on_fail
48+
async def test_charmed_dba_role(ops_test: OpsTest):
49+
"""Test the DBA predefined role."""
50+
await ops_test.model.applications[DATA_INTEGRATOR_APP_NAME].set_config({
51+
"database-name": "charmed_dba_database",
52+
"extra-user-roles": "charmed_dba",
53+
})
54+
await ops_test.model.add_relation(DATA_INTEGRATOR_APP_NAME, DATABASE_APP_NAME)
55+
await ops_test.model.wait_for_idle(
56+
apps=[DATA_INTEGRATOR_APP_NAME, DATABASE_APP_NAME], status="active"
57+
)
58+
59+
action = await ops_test.model.units[f"{DATA_INTEGRATOR_APP_NAME}/0"].run_action(
60+
action_name="get-credentials"
61+
)
62+
result = await action.wait()
63+
data_integrator_credentials = result.results
64+
username = data_integrator_credentials["postgresql"]["username"]
65+
66+
for read_write_endpoint in [True, False]:
67+
connection_string = await build_connection_string(
68+
ops_test,
69+
DATA_INTEGRATOR_APP_NAME,
70+
"postgresql",
71+
database="charmed_dba_database",
72+
read_only_endpoint=(not read_write_endpoint),
73+
)
74+
connection = psycopg2.connect(connection_string)
75+
connection.autocommit = True
76+
try:
77+
with connection.cursor() as cursor:
78+
instance = "primary" if read_write_endpoint else "replica"
79+
logger.info(f"Testing escalation to the rewind user in the {instance}")
80+
cursor.execute("SELECT set_user('rewind'::TEXT);")
81+
check_connected_user(cursor, username, "rewind", primary=read_write_endpoint)
82+
logger.info(f"Resetting the user to the {username} user in the {instance}")
83+
cursor.execute("SELECT reset_user();")
84+
check_connected_user(cursor, username, username, primary=read_write_endpoint)
85+
logger.info(f"Testing escalation to the operator user in the {instance}")
86+
cursor.execute("SELECT set_user_u('operator'::TEXT);")
87+
check_connected_user(cursor, username, "operator", primary=read_write_endpoint)
88+
logger.info(f"Resetting the user to the {username} user in the {instance}")
89+
cursor.execute("SELECT reset_user();")
90+
check_connected_user(cursor, username, username, primary=read_write_endpoint)
91+
finally:
92+
if connection is not None:
93+
connection.close()

tests/integration/test_predefined_roles.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .helpers import (
1414
CHARM_BASE,
15+
DATA_INTEGRATOR_APP_NAME,
1516
DATABASE_APP_NAME,
1617
db_connect,
1718
get_password,
@@ -27,7 +28,6 @@
2728
logger = logging.getLogger(__name__)
2829

2930
TIMEOUT = 15 * 60
30-
DATA_INTEGRATOR_APP_NAME = "data-integrator"
3131

3232
# NOTE: We are unable to test set_user_u('operator') as dba user because psycopg2
3333
# runs every query in a transaction and running set_user() is not supported in transactions.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
summary: test_predefined_dba_role.py
2+
environment:
3+
TEST_MODULE: test_predefined_dba_role.py
4+
execute: |
5+
tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results"
6+
artifacts:
7+
- allure-results

0 commit comments

Comments
 (0)