Skip to content

Commit f827f3c

Browse files
[DRAFT]
1 parent abca727 commit f827f3c

File tree

8 files changed

+192
-19
lines changed

8 files changed

+192
-19
lines changed

lib/charms/mysql/v0/mysql.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,13 @@ def wait_until_mysql_connection(self) -> None:
158158
ROLE_READ = "charmed_read"
159159
ROLE_STATS = "charmed_stats"
160160
ROLE_BACKUP = "charmed_backup"
161+
ROLE_MAX_LENGTH = 32
161162

162163
# TODO:
163164
# Remove legacy role when migrating to MySQL 8.4
164165
# (when breaking changes are allowed)
165166
LEGACY_ROLE_ROUTER = "mysqlrouter"
166-
MODERN_ROLE_ROUTER = "charmed_mysqlrouter"
167+
MODERN_ROLE_ROUTER = "charmed_router"
167168

168169
FORBIDDEN_EXTRA_ROLES = {
169170
ROLE_BACKUP,
@@ -1166,7 +1167,7 @@ def configure_mysql_system_roles(self) -> None:
11661167
ROLE_STATS: [
11671168
f"CREATE ROLE {ROLE_STATS}",
11681169
f"GRANT SELECT ON performance_schema.* TO {ROLE_STATS}",
1169-
f"GRANT PROCESS, REPLICATION CLIENT ON *.* TO {ROLE_STATS}",
1170+
f"GRANT PROCESS, RELOAD, REPLICATION CLIENT ON *.* TO {ROLE_STATS}",
11701171
],
11711172
ROLE_BACKUP: [
11721173
f"CREATE ROLE {ROLE_BACKUP}",
@@ -1177,15 +1178,15 @@ def configure_mysql_system_roles(self) -> None:
11771178
ROLE_DDL: [
11781179
f"CREATE ROLE {ROLE_DDL}",
11791180
f"GRANT charmed_dml TO {ROLE_DDL}",
1180-
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}",
1181+
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}",
11811182
],
11821183
ROLE_DBA: [
11831184
f"CREATE ROLE {ROLE_DBA}",
11841185
f"GRANT charmed_dml TO {ROLE_DBA}",
11851186
f"GRANT charmed_stats TO {ROLE_DBA}",
11861187
f"GRANT charmed_backup TO {ROLE_DBA}",
11871188
f"GRANT charmed_ddl TO {ROLE_DBA}",
1188-
f"GRANT EVENT, FILE, SHOW DATABASES, SHUTDOWN ON *.* TO {ROLE_DBA}",
1189+
f"GRANT EVENT, SHOW DATABASES, SHUTDOWN ON *.* TO {ROLE_DBA}",
11891190
f"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON *.* TO {ROLE_DBA}",
11901191
f"GRANT AUDIT_ADMIN, CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN ON *.* TO {ROLE_DBA}",
11911192
],
@@ -1427,16 +1428,30 @@ def configure_mysqlrouter_user(
14271428

14281429
def create_database(self, database: str) -> None:
14291430
"""Create an application database."""
1431+
role_name = f"charmed_dba_{database}"
1432+
1433+
if len(database) >= ROLE_MAX_LENGTH:
1434+
logger.error(f"Failed to create application database {database}")
1435+
raise MySQLCreateApplicationDatabaseError("Name longer than 32 characters")
1436+
if len(role_name) >= ROLE_MAX_LENGTH:
1437+
logger.warning(f"Pruning application database role name {role_name}")
1438+
role_name = role_name[:ROLE_MAX_LENGTH]
1439+
14301440
create_database_commands = (
14311441
"shell.connect_to_primary()",
14321442
f'session.run_sql("CREATE DATABASE IF NOT EXISTS `{database}`;")',
14331443
f'session.run_sql("GRANT SELECT ON `{database}`.* TO {ROLE_READ};")',
14341444
f'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE ON `{database}`.* TO {ROLE_DML};")',
14351445
)
1446+
create_dba_role_commands = (
1447+
f'session.run_sql("CREATE ROLE IF NOT EXISTS `{role_name}`;")',
1448+
f'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE, EXECUTE ON `{database}`.* TO {role_name};")',
1449+
f'session.run_sql("GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, TRIGGER ON `{database}`.* TO {role_name};")',
1450+
)
14361451

14371452
try:
14381453
self._run_mysqlsh_script(
1439-
"\n".join(create_database_commands),
1454+
"\n".join(create_database_commands + create_dba_role_commands),
14401455
user=self.server_config_user,
14411456
password=self.server_config_password,
14421457
host=self.instance_def(self.server_config_user),
@@ -1456,23 +1471,25 @@ def create_scoped_user(
14561471
extra_roles: Optional[List[str]] = None,
14571472
) -> None:
14581473
"""Create an application user scoped to the created database."""
1474+
if extra_roles is not None and set(extra_roles) & FORBIDDEN_EXTRA_ROLES:
1475+
logger.error(f"Invalid extra user roles: {extra_roles}")
1476+
raise MySQLCreateApplicationScopedUserError("invalid role(s) for extra user roles")
1477+
14591478
attributes = {}
14601479
if unit_name is not None:
1461-
attributes["unit_name"] = unit_name
1480+
attributes = {"unit_name": unit_name}
1481+
if extra_roles is not None:
1482+
extra_roles = ", ".join(extra_roles)
14621483

14631484
create_scoped_user_attributes = json.dumps(attributes).replace('"', r"\"")
14641485
create_scoped_user_commands = (
14651486
"shell.connect_to_primary()",
14661487
f"session.run_sql(\"CREATE USER `{username}`@`{hostname}` IDENTIFIED BY '{password}' ATTRIBUTE '{create_scoped_user_attributes}';\")",
14671488
)
14681489

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

12-
from . import juju_
13-
from .helpers import (
12+
from .. import juju_
13+
from ..helpers import (
1414
execute_queries_on_unit,
1515
get_primary_unit,
1616
get_server_config_credentials,

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

Lines changed: 2 additions & 2 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,

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)