Skip to content

Commit e9ba0fb

Browse files
[DRAFT]
1 parent 256d8ad commit e9ba0fb

File tree

9 files changed

+225
-38
lines changed

9 files changed

+225
-38
lines changed

lib/charms/mysql/v0/mysql.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def wait_until_mysql_connection(self) -> None:
152152
ROLE_READ = "charmed_read"
153153
ROLE_STATS = "charmed_stats"
154154
ROLE_BACKUP = "charmed_backup"
155+
ROLE_MAX_LENGTH = 32
155156

156157
# TODO:
157158
# Remove legacy role when migrating to MySQL 8.4
@@ -1251,15 +1252,15 @@ def configure_mysql_system_roles(self) -> None:
12511252
ROLE_DDL: [
12521253
f"CREATE ROLE {ROLE_DDL}",
12531254
f"GRANT charmed_dml TO {ROLE_DDL}",
1254-
f"GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE TABLESPACE, CREATE VIEW, SHOW_ROUTINE, SHOW VIEW, INDEX, REFERENCES, TRIGGER, LOCK TABLES ON *.* TO {ROLE_DDL}",
1255+
f"GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE TABLESPACE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, SHOW_ROUTINE, SHOW VIEW, TRIGGER ON *.* TO {ROLE_DDL}",
12551256
],
12561257
ROLE_DBA: [
12571258
f"CREATE ROLE {ROLE_DBA}",
12581259
f"GRANT charmed_dml TO {ROLE_DBA}",
12591260
f"GRANT charmed_stats TO {ROLE_DBA}",
12601261
f"GRANT charmed_backup TO {ROLE_DBA}",
12611262
f"GRANT charmed_ddl TO {ROLE_DBA}",
1262-
f"GRANT EVENT, FILE, SHOW DATABASES, SHUTDOWN ON *.* TO {ROLE_DBA}",
1263+
f"GRANT EVENT, SHOW DATABASES, SHUTDOWN ON *.* TO {ROLE_DBA}",
12631264
f"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON *.* TO {ROLE_DBA}",
12641265
f"GRANT AUDIT_ADMIN, CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN ON *.* TO {ROLE_DBA}",
12651266
],
@@ -1501,16 +1502,30 @@ def configure_mysqlrouter_user(
15011502

15021503
def create_database(self, database: str) -> None:
15031504
"""Create an application database."""
1505+
role_name = f"charmed_dba_{database}"
1506+
1507+
if len(database) >= ROLE_MAX_LENGTH:
1508+
logger.error(f"Failed to create application database {database}")
1509+
raise MySQLCreateApplicationDatabaseError("Name longer than 32 characters")
1510+
if len(role_name) >= ROLE_MAX_LENGTH:
1511+
logger.warning(f"Pruning application database role name {role_name}")
1512+
role_name = role_name[:ROLE_MAX_LENGTH]
1513+
15041514
create_database_commands = (
15051515
"shell.connect_to_primary()",
15061516
f'session.run_sql("CREATE DATABASE IF NOT EXISTS `{database}`;")',
15071517
f'session.run_sql("GRANT SELECT ON `{database}`.* TO {ROLE_READ};")',
15081518
f'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE ON `{database}`.* TO {ROLE_DML};")',
15091519
)
1520+
create_dba_role_commands = (
1521+
f'session.run_sql("CREATE ROLE IF NOT EXISTS `{role_name}`;")',
1522+
f'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE, EXECUTE ON `{database}`.* TO {role_name};")',
1523+
f'session.run_sql("GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, TRIGGER ON `{database}`.* TO {role_name};")',
1524+
)
15101525

15111526
try:
15121527
self._run_mysqlsh_script(
1513-
"\n".join(create_database_commands),
1528+
"\n".join(create_database_commands + create_dba_role_commands),
15141529
user=self.server_config_user,
15151530
password=self.server_config_password,
15161531
host=self.instance_def(self.server_config_user),
@@ -1530,23 +1545,25 @@ def create_scoped_user(
15301545
extra_roles: list[str] | None = None,
15311546
) -> None:
15321547
"""Create an application user scoped to the created database."""
1548+
if extra_roles is not None and set(extra_roles) & FORBIDDEN_EXTRA_ROLES:
1549+
logger.error(f"Invalid extra user roles: {extra_roles}")
1550+
raise MySQLCreateApplicationScopedUserError("invalid role(s) for extra user roles")
1551+
15331552
attributes = {}
15341553
if unit_name is not None:
1535-
attributes["unit_name"] = unit_name
1554+
attributes = {"unit_name": unit_name}
1555+
if extra_roles is not None:
1556+
extra_roles = ", ".join(extra_roles)
15361557

15371558
create_scoped_user_attributes = json.dumps(attributes).replace('"', r"\"")
15381559
create_scoped_user_commands = (
15391560
"shell.connect_to_primary()",
15401561
f"session.run_sql(\"CREATE USER `{username}`@`{hostname}` IDENTIFIED BY '{password}' ATTRIBUTE '{create_scoped_user_attributes}';\")",
15411562
)
15421563

1543-
if extra_roles and set(extra_roles) & FORBIDDEN_EXTRA_ROLES:
1544-
logger.error(f"Invalid extra user roles: {', '.join(extra_roles)}")
1545-
raise MySQLCreateApplicationScopedUserError("invalid role(s) for extra user roles")
1546-
15471564
if extra_roles:
15481565
grant_scoped_user_commands = (
1549-
f"session.run_sql(\"GRANT {','.join(extra_roles)} TO `{username}`@`{hostname}`;\")",
1566+
f'session.run_sql("GRANT {extra_roles} TO `{username}`@`{hostname}`;")',
15501567
)
15511568
else:
15521569
# Legacy behaviour when no explicit roles were assigned to users

tests/integration/roles/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
from pathlib import Path
8+
9+
import pytest
10+
import yaml
11+
from mysql.connector.errors import ProgrammingError
12+
from pytest_operator.plugin import OpsTest
13+
14+
from .. import juju_
15+
from ..helpers import (
16+
execute_queries_on_unit,
17+
get_primary_unit,
18+
)
19+
20+
logger = logging.getLogger(__name__)
21+
22+
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
23+
24+
DATABASE_APP_NAME = METADATA["name"]
25+
INTEGRATOR_APP_NAME = "data-integrator"
26+
27+
28+
@pytest.mark.abort_on_fail
29+
async def test_build_and_deploy(ops_test: OpsTest, charm) -> None:
30+
"""Simple test to ensure that the mysql and data-integrator charms get deployed."""
31+
async with ops_test.fast_forward("10s"):
32+
await asyncio.gather(
33+
ops_test.model.deploy(
34+
charm,
35+
application_name=DATABASE_APP_NAME,
36+
num_units=3,
37+
38+
config={"profile": "testing"},
39+
),
40+
ops_test.model.deploy(
41+
INTEGRATOR_APP_NAME,
42+
application_name=f"{INTEGRATOR_APP_NAME}1",
43+
44+
),
45+
ops_test.model.deploy(
46+
INTEGRATOR_APP_NAME,
47+
application_name=f"{INTEGRATOR_APP_NAME}2",
48+
49+
),
50+
)
51+
52+
await ops_test.model.wait_for_idle(
53+
apps=[DATABASE_APP_NAME],
54+
status="active",
55+
)
56+
await ops_test.model.wait_for_idle(
57+
apps=[f"{INTEGRATOR_APP_NAME}1", f"{INTEGRATOR_APP_NAME}2"],
58+
status="blocked",
59+
)
60+
61+
62+
@pytest.mark.abort_on_fail
63+
async def test_charmed_dba_role(ops_test: OpsTest):
64+
"""Test the database-level DBA role."""
65+
await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({
66+
"database-name": "preserved",
67+
"extra-user-roles": "",
68+
})
69+
await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME)
70+
await ops_test.model.wait_for_idle(
71+
apps=[f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME],
72+
status="active",
73+
)
74+
75+
await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}2"].set_config({
76+
"database-name": "throwaway",
77+
"extra-user-roles": "charmed_dba_preserved",
78+
})
79+
await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}2", DATABASE_APP_NAME)
80+
await ops_test.model.wait_for_idle(
81+
apps=[f"{INTEGRATOR_APP_NAME}2", DATABASE_APP_NAME],
82+
status="active",
83+
)
84+
85+
mysql_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0]
86+
primary_unit = await get_primary_unit(ops_test, mysql_unit, DATABASE_APP_NAME)
87+
primary_unit_address = await primary_unit.get_public_address()
88+
89+
data_integrator_2_unit = ops_test.model.applications[f"{INTEGRATOR_APP_NAME}2"].units[0]
90+
results = await juju_.run_action(data_integrator_2_unit, "get-credentials")
91+
92+
logger.info("Checking that the database-level DBA role cannot create new databases")
93+
with pytest.raises(ProgrammingError):
94+
await execute_queries_on_unit(
95+
primary_unit_address,
96+
results["mysql"]["username"],
97+
results["mysql"]["password"],
98+
["CREATE DATABASE IF NOT EXISTS test"],
99+
commit=True,
100+
)
101+
102+
logger.info("Checking that the database-level DBA role can see all databases")
103+
await execute_queries_on_unit(
104+
primary_unit_address,
105+
results["mysql"]["username"],
106+
results["mysql"]["password"],
107+
["SHOW DATABASES"],
108+
commit=True,
109+
)
110+
111+
logger.info("Checking that the database-level DBA role can create a new table")
112+
await execute_queries_on_unit(
113+
primary_unit_address,
114+
results["mysql"]["username"],
115+
results["mysql"]["password"],
116+
[
117+
"CREATE TABLE preserved.test_table (`id` SERIAL PRIMARY KEY, `data` TEXT)",
118+
],
119+
commit=True,
120+
)
121+
122+
logger.info("Checking that the database-level DBA role can write into an existing table")
123+
await execute_queries_on_unit(
124+
primary_unit_address,
125+
results["mysql"]["username"],
126+
results["mysql"]["password"],
127+
[
128+
"INSERT INTO preserved.test_table (`data`) VALUES ('test_data_1', 'test_data_2')",
129+
],
130+
commit=True,
131+
)
132+
133+
logger.info("Checking that the database-level DBA role can read from an existing table")
134+
rows = await execute_queries_on_unit(
135+
primary_unit_address,
136+
results["mysql"]["username"],
137+
results["mysql"]["password"],
138+
[
139+
"SELECT `data` FROM preserved.test_table",
140+
],
141+
commit=True,
142+
)
143+
assert sorted(rows) == sorted([
144+
"test_data_1",
145+
"test_data_2",
146+
]), "Unexpected data in preserved with charmed_dba_preserved role"

tests/integration/test_predefined_dba_role.py renamed to tests/integration/roles/test_instance_dba_role.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@
33
# See LICENSE file for licensing details.
44

55
import asyncio
6+
import logging
67
from pathlib import Path
78

89
import pytest
910
import yaml
1011
from pytest_operator.plugin import OpsTest
1112

12-
from . import juju_
13-
from .helpers import (
13+
from .. import juju_
14+
from ..helpers import (
1415
execute_queries_on_unit,
1516
get_primary_unit,
16-
get_server_config_credentials,
1717
)
1818

19+
logger = logging.getLogger(__name__)
20+
1921
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
2022

2123
DATABASE_APP_NAME = METADATA["name"]
@@ -46,7 +48,7 @@ async def test_build_and_deploy(ops_test: OpsTest, charm) -> None:
4648

4749
@pytest.mark.abort_on_fail
4850
async def test_charmed_dba_role(ops_test: OpsTest):
49-
"""Test the DBA predefined role."""
51+
"""Test the instance-level DBA role."""
5052
await ops_test.model.applications[INTEGRATOR_APP_NAME].set_config({
5153
"database-name": "charmed_dba_database",
5254
"extra-user-roles": "charmed_dba",
@@ -59,19 +61,23 @@ async def test_charmed_dba_role(ops_test: OpsTest):
5961
mysql_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0]
6062
primary_unit = await get_primary_unit(ops_test, mysql_unit, DATABASE_APP_NAME)
6163
primary_unit_address = await primary_unit.get_public_address()
62-
server_config_credentials = await get_server_config_credentials(primary_unit)
6364

65+
data_integrator_unit = ops_test.model.applications[INTEGRATOR_APP_NAME].units[0]
66+
results = await juju_.run_action(data_integrator_unit, "get-credentials")
67+
68+
logger.info("Checking that the instance-level DBA role can create new databases")
6469
await execute_queries_on_unit(
6570
primary_unit_address,
66-
server_config_credentials["username"],
67-
server_config_credentials["password"],
71+
results["mysql"]["username"],
72+
results["mysql"]["password"],
6873
["CREATE DATABASE IF NOT EXISTS test"],
6974
commit=True,
7075
)
7176

7277
data_integrator_unit = ops_test.model.applications[INTEGRATOR_APP_NAME].units[0]
7378
results = await juju_.run_action(data_integrator_unit, "get-credentials")
7479

80+
logger.info("Checking that the instance-level DBA role can see all databases")
7581
rows = await execute_queries_on_unit(
7682
primary_unit_address,
7783
results["mysql"]["username"],

tests/integration/test_predefined_roles.py renamed to tests/integration/roles/test_instance_roles.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
from mysql.connector.errors import ProgrammingError
1212
from pytest_operator.plugin import OpsTest
1313

14-
from . import juju_
15-
from .helpers import (
14+
from .. import juju_
15+
from ..helpers import (
1616
execute_queries_on_unit,
1717
get_primary_unit,
1818
get_server_config_credentials,
@@ -62,7 +62,7 @@ async def test_build_and_deploy(ops_test: OpsTest, charm) -> None:
6262

6363
@pytest.mark.abort_on_fail
6464
async def test_charmed_read_role(ops_test: OpsTest):
65-
"""Test the charmed_read predefined role."""
65+
"""Test the instance-level charmed_read role."""
6666
await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({
6767
"database-name": "charmed_read_database",
6868
"extra-user-roles": "charmed_read",
@@ -143,7 +143,7 @@ async def test_charmed_read_role(ops_test: OpsTest):
143143

144144
@pytest.mark.abort_on_fail
145145
async def test_charmed_dml_role(ops_test: OpsTest):
146-
"""Test the charmed_dml role."""
146+
"""Test the instance-level charmed_dml role."""
147147
await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({
148148
"database-name": "charmed_dml_database",
149149
"extra-user-roles": "",

tests/spread/test_predefined_dba_role.py/task.yaml renamed to tests/spread/test_database_dba_role.py/task.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
summary: test_predefined_dba_role.py
1+
summary: test_database_dba_role.py
22
environment:
3-
TEST_MODULE: test_predefined_dba_role.py
3+
TEST_MODULE: roles/test_database_dba_role.py
44
execute: |
55
tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results"
66
artifacts:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
summary: test_instance_dba_role.py
2+
environment:
3+
TEST_MODULE: roles/test_instance_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

tests/spread/test_predefined_roles.py/task.yaml renamed to tests/spread/test_instance_roles.py/task.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
summary: test_predefined_roles.py
1+
summary: test_instance_roles.py
22
environment:
3-
TEST_MODULE: test_predefined_roles.py
3+
TEST_MODULE: roles/test_instance_roles.py
44
execute: |
55
tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results"
66
artifacts:

0 commit comments

Comments
 (0)