Skip to content

Commit 81ed6d1

Browse files
committed
feat(mysql): make root_password configurable, generate at creation time
Bug 2 fix from issue #410. - Add optional root_password field to environment config JSON schema - MysqlConfig.root_password is Password (non-optional); domain always has a value - MysqlConfigRaw.root_password is Option<Password> for backward compat with persisted environments that pre-date this field - Add src/shared/secrets/random.rs: generate_random_password() -> Password using rand::rng(), mixed charset (lower+upper+digit+symbol), length 32, satisfies MySQL validate_password MEDIUM policy - Generate root_password once in TryFrom<DatabaseSection> at environment creation time (not at render time) so it is stable across re-renders - Remove format!("{password}_root") derivation from create_mysql_contexts - Update spec docs/issues/410-bug-multiple-mysql-configuration-issues.md to reflect the actual implementation
1 parent 2ec8f1e commit 81ed6d1

13 files changed

Lines changed: 276 additions & 55 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ clap = { version = "4.0", features = [ "derive" ] }
5050
derive_more = { version = "2.1", features = [ "display", "from" ] }
5151
figment = { version = "0.10", features = [ "json" ] }
5252
parking_lot = "0.12"
53+
rand = "0.9"
5354
reqwest = "0.12"
5455
rust-embed = "8.0"
5556
schemars = "1.1"

docs/issues/410-bug-multiple-mysql-configuration-issues.md

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ value.
4646

4747
### Bug 2 — Root password not configurable
4848

49-
- [ ] Add an optional `root_password` field to the MySQL section of the environment
49+
- [x] Add an optional `root_password` field to the MySQL section of the environment
5050
configuration JSON schema
51-
- [ ] When a root password is provided by the user, use it; when omitted, generate a
52-
strong random password at render time rather than deriving it from the app password
53-
- [ ] Remove the `format!("{password}_root")` derivation from `create_mysql_contexts`
51+
- [x] When a root password is provided by the user, use it; when omitted, generate a
52+
strong random password at environment creation time (application layer) rather
53+
than deriving it from the app password
54+
- [x] Remove the `format!("{password}_root")` derivation from `create_mysql_contexts`
5455

5556
### Bug 3 — Reserved username not rejected
5657

@@ -174,21 +175,44 @@ no `root_password` field for MySQL. The deployer therefore always sets
174175

175176
**Fix**: Add an optional `root_password` to the MySQL section of the environment
176177
configuration JSON. If the user provides it, use it. If they omit it, generate a
177-
cryptographically random password at render time instead of deriving it from the app
178-
password. Remove the `format!("{password}_root")` derivation.
178+
cryptographically random password at **environment creation time** (application layer)
179+
instead of deriving it from the app password. Remove the `format!("{password}_root")`
180+
derivation.
181+
182+
**Key design decision — generate at creation time, not render time**: The root password
183+
is a domain invariant that must remain stable across multiple renders (e.g. re-deploying
184+
without reprovisioning). Generating it at render time would produce a different
185+
`MYSQL_ROOT_PASSWORD` on each render, breaking MySQL container restarts. Instead,
186+
generation happens once in the application layer (`TryFrom<DatabaseSection>`) when the
187+
environment is first created, and the value is persisted alongside the rest of the
188+
environment config.
179189

180190
**Affected modules and types**:
181191

192+
- `Cargo.toml`: add `rand = "0.9"` dependency.
182193
- `schemas/environment-config.json`: add optional `root_password` string to the MySQL
183194
database object.
184-
- `src/domain/tracker/config/core/database/mysql.rs` (`MysqlConfig`): add optional
185-
`root_password` field; update constructor and accessors.
186-
- `src/presentation/cli/controllers/create/subcommands/environment/config_loader.rs`
187-
(or equivalent deserialization path): propagate the optional field through to the
188-
domain type.
195+
- `src/shared/secrets/random.rs` (new file): `generate_random_password() -> Password`
196+
using `rand::rng()` (ThreadRng seeded from OsRng), guaranteeing one character from each
197+
class (lower, upper, digit, symbol), filled to 32 characters, then shuffled. Satisfies
198+
MySQL `validate_password MEDIUM` policy.
199+
- `src/shared/secrets/mod.rs` and `src/shared/mod.rs`: re-export
200+
`generate_random_password`.
201+
- `src/domain/tracker/config/core/database/mysql.rs` (`MysqlConfig`): `root_password`
202+
field is `Password` (non-optional) — the domain type always has a value. Constructor
203+
`new()` takes `root_password: Password`. Accessor `root_password() -> &Password` added.
204+
`MysqlConfigRaw` (the serde deserialization intermediate) keeps
205+
`root_password: Option<Password>` with `#[serde(default)]` for backward compatibility
206+
with persisted environments that pre-date this field; missing values are filled by
207+
calling `generate_random_password()` during deserialization.
208+
- `src/application/command_handlers/create/config/tracker/tracker_core_section.rs`
209+
(`TryFrom<DatabaseSection>`): generation happens here — the optional user-supplied
210+
`root_password` is mapped to `Password` if present, or `generate_random_password()` is
211+
called if absent. This is the single point of generation for new environments.
189212
- `src/application/services/rendering/docker_compose.rs` (`create_mysql_contexts`):
190-
replace `format!("{password}_root")` with either the user-supplied root password or a
191-
freshly generated random password.
213+
`root_password` parameter is now `PlainPassword` (non-optional); call site passes
214+
`mysql_config.root_password().expose_secret().to_string()`. No generation logic
215+
remains here.
192216

193217
### Bug 3 — Reserved MySQL Username `"root"` Not Rejected
194218

@@ -237,24 +261,27 @@ Tasks are ordered from simplest to most complex.
237261

238262
### Phase 1: Reject reserved MySQL username (Bug 3)
239263

240-
- [ ] In `MysqlConfigError` (`mysql.rs`): add `ReservedUsername` variant
241-
- [ ] Add `help()` arm for `ReservedUsername` with actionable fix instructions
242-
- [ ] In `MysqlConfig::new()`: add `if username == "root"` guard returning
264+
- [x] In `MysqlConfigError` (`mysql.rs`): add `ReservedUsername` variant
265+
- [x] Add `help()` arm for `ReservedUsername` with actionable fix instructions
266+
- [x] In `MysqlConfig::new()`: add `if username == "root"` guard returning
243267
`Err(MysqlConfigError::ReservedUsername)`
244-
- [ ] Add unit test `it_should_reject_root_as_username`
268+
- [x] Add unit test `it_should_reject_root_as_username`
245269

246270
### Phase 2: Make root password configurable (Bug 2)
247271

248-
- [ ] `schemas/environment-config.json`: add optional `root_password` string to the
272+
- [x] `schemas/environment-config.json`: add optional `root_password` string to the
249273
MySQL database object
250-
- [ ] `MysqlConfig` (`mysql.rs`): add optional `root_password` field; update constructor
251-
and accessor
252-
- [ ] Deserialization/config-loading path: thread the optional field through to the
253-
application layer
254-
- [ ] `create_mysql_contexts` (`docker_compose.rs`): replace
255-
`format!("{password}_root")` with user-supplied value or a randomly generated
256-
password (use `rand` / `getrandom` — already in `Cargo.toml` — to produce a
257-
16+ character alphanumeric string)
274+
- [x] `MysqlConfig` (`mysql.rs`): `root_password` is `Password` (non-optional) in the
275+
domain — always has a value. `MysqlConfigRaw` uses `Option<Password>` for backward
276+
compat with persisted environments lacking the field.
277+
- [x] `src/shared/secrets/random.rs` (new): `generate_random_password() -> Password`
278+
using mixed charset (lower + upper + digit + symbol), length 32, satisfies MySQL
279+
MEDIUM password policy
280+
- [x] `TryFrom<DatabaseSection>` (`tracker_core_section.rs`): generates root password at
281+
environment creation time — not at render time — so it is stable across re-renders
282+
- [x] `create_mysql_contexts` (`docker_compose.rs`): replaced `format!("{password}_root")`
283+
with `mysql_config.root_password().expose_secret().to_string()`; no generation
284+
logic remains here
258285

259286
### Phase 3: Move DSN to env var override and add URL-encoding (Bug 1)
260287

@@ -308,13 +335,17 @@ Tasks are ordered from simplest to most complex.
308335

309336
**Task-Specific Criteria — Bug 2 (root password)**:
310337

311-
- [ ] The environment configuration JSON schema accepts an optional `root_password` field
338+
- [x] The environment configuration JSON schema accepts an optional `root_password` field
312339
in the MySQL database object
313-
- [ ] When `root_password` is provided in the env JSON it is used as `MYSQL_ROOT_PASSWORD`
340+
- [x] When `root_password` is provided in the env JSON it is used as `MYSQL_ROOT_PASSWORD`
314341
in the rendered `.env`
315-
- [ ] When `root_password` is omitted, a randomly generated password is used — it is
342+
- [x] When `root_password` is omitted, a randomly generated password is used — it is
316343
**not** derived from the app password
317-
- [ ] `create_mysql_contexts` no longer contains `format!("{password}_root")`
344+
- [x] `create_mysql_contexts` no longer contains `format!("{password}_root")`
345+
- [x] The random password is generated once at environment creation time (not at render
346+
time), ensuring stability across multiple renders
347+
- [x] The domain type `MysqlConfig.root_password` is always populated (`Password`,
348+
non-optional)
318349

319350
**Task-Specific Criteria — Bug 1 (DSN in tracker.toml)**:
320351

schemas/environment-config.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@
145145
"username": {
146146
"description": "Database username",
147147
"type": "string"
148+
},
149+
"root_password": {
150+
"description": "Optional `MySQL` root password\n\nWhen provided, used as `MYSQL_ROOT_PASSWORD` in the rendered `.env` file.\nWhen absent, a cryptographically random password is generated at render time.\nNever set this to the same value as `password` in production environments.",
151+
"type": [
152+
"string",
153+
"null"
154+
],
155+
"default": null
148156
}
149157
},
150158
"required": [
@@ -515,4 +523,4 @@
515523
]
516524
}
517525
}
518-
}
526+
}

src/application/command_handlers/create/config/builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ impl EnvironmentCreationConfigBuilder {
203203
database_name: database_name.into(),
204204
username: username.into(),
205205
password: password.into(),
206+
root_password: None,
206207
});
207208
self
208209
}

src/application/command_handlers/create/config/tracker/tracker_core_section.rs

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
1616

1717
use crate::application::command_handlers::create::config::errors::CreateConfigError;
1818
use crate::domain::tracker::{DatabaseConfig, MysqlConfig, SqliteConfig, TrackerCoreConfig};
19-
use crate::shared::{Password, PlainPassword};
19+
use crate::shared::{generate_random_password, Password, PlainPassword};
2020

2121
/// Database configuration section (application DTO)
2222
///
@@ -67,6 +67,12 @@ pub enum DatabaseSection {
6767
/// Uses `PlainPassword` type alias to explicitly mark this as a temporarily visible secret.
6868
/// Converted to secure `Password` type in `to_database_config()` at the DTO-to-domain boundary.
6969
password: PlainPassword,
70+
/// Optional `MySQL` root password
71+
///
72+
/// When provided, used as `MYSQL_ROOT_PASSWORD` in the rendered `.env` file.
73+
/// When absent, a cryptographically random password is generated at environment creation time.
74+
#[serde(default)]
75+
root_password: Option<PlainPassword>,
7076
},
7177
}
7278

@@ -85,13 +91,18 @@ impl TryFrom<DatabaseSection> for DatabaseConfig {
8591
database_name,
8692
username,
8793
password,
94+
root_password,
8895
} => {
96+
let root_password = root_password
97+
.map(|p| Password::from(p.as_str()))
98+
.unwrap_or_else(generate_random_password);
8999
let config = MysqlConfig::new(
90100
host,
91101
port,
92102
database_name,
93103
username,
94104
Password::from(password.as_str()),
105+
root_password,
95106
)?;
96107
Ok(Self::Mysql(config))
97108
}
@@ -212,25 +223,23 @@ mod tests {
212223
database_name: "tracker".to_string(),
213224
username: "tracker_user".to_string(),
214225
password: "secure_password".to_string(),
226+
root_password: None,
215227
},
216228
private: false,
217229
};
218230

219231
let config: TrackerCoreConfig = section.try_into().unwrap();
220232

221-
assert_eq!(
222-
*config.database(),
223-
DatabaseConfig::Mysql(
224-
MysqlConfig::new(
225-
"localhost",
226-
3306,
227-
"tracker",
228-
"tracker_user",
229-
Password::from("secure_password"),
230-
)
231-
.unwrap()
232-
)
233-
);
233+
let DatabaseConfig::Mysql(mysql) = config.database() else {
234+
panic!("expected MySQL config");
235+
};
236+
assert_eq!(mysql.host(), "localhost");
237+
assert_eq!(mysql.port(), 3306);
238+
assert_eq!(mysql.database_name(), "tracker");
239+
assert_eq!(mysql.username(), "tracker_user");
240+
assert_eq!(mysql.password().expose_secret(), "secure_password");
241+
// root_password is generated randomly — just verify it is non-empty
242+
assert!(!mysql.root_password().expose_secret().is_empty());
234243
assert!(!config.private());
235244
}
236245

@@ -243,6 +252,7 @@ mod tests {
243252
database_name: "tracker".to_string(),
244253
username: "tracker_user".to_string(),
245254
password: "pass123".to_string(),
255+
root_password: None,
246256
},
247257
private: false,
248258
};
@@ -280,6 +290,7 @@ mod tests {
280290
database_name: "tracker".to_string(),
281291
username: "tracker_user".to_string(),
282292
password: "secure_password".to_string(),
293+
root_password: None,
283294
}
284295
);
285296
assert!(!section.private);

src/application/services/rendering/docker_compose.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ impl DockerComposeTemplateRenderingService {
111111
mysql_config.database_name().to_string(),
112112
mysql_config.username().to_string(),
113113
mysql_config.password().expose_secret().to_string(),
114+
mysql_config.root_password().expose_secret().to_string(),
114115
),
115116
};
116117

@@ -217,10 +218,8 @@ impl DockerComposeTemplateRenderingService {
217218
database_name: String,
218219
username: String,
219220
password: PlainPassword,
221+
root_password: PlainPassword,
220222
) -> (EnvContext, DockerComposeContextBuilder) {
221-
// For MySQL, generate a secure root password (in production, this should be managed securely)
222-
let root_password = format!("{password}_root");
223-
224223
let metadata = TemplateMetadata::new(self.clock.now());
225224
let env_context = EnvContext::new_with_mysql(
226225
metadata.clone(),

src/domain/tracker/config/core/database/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ mod tests {
139139
"tracker",
140140
"tracker_user",
141141
Password::from("secure_password"),
142+
Password::from("root_pass"),
142143
)
143144
.unwrap(),
144145
);
@@ -156,6 +157,7 @@ mod tests {
156157
"tracker",
157158
"tracker_user",
158159
Password::from("pass123"),
160+
Password::from("root123"),
159161
)
160162
.unwrap(),
161163
);

0 commit comments

Comments
 (0)