Skip to content

Commit e114867

Browse files
committed
Merge #245: feat: [#243] introduce SecretString-based secret types for sensitive data
fa24387 feat: [#243] convert HTTP API admin token and SSH test password to secret types (Jose Celano) 7521a4f feat: [#243] add debug tracing for secret access (Jose Celano) 96ee872 docs: [#243] mark Proposal #9 and #10 as completed/verified (Jose Celano) 57a6c91 docs: [#243] add comprehensive secret handling guide and update AGENTS.md (Jose Celano) 2981fc8 feat: [#243] introduce SecretString-based secret types with folder module structure (Jose Celano) Pull request description: ## Summary This PR implements issue #243, introducing type-safe wrappers for sensitive data (API tokens, passwords) using the `secrecy` crate. This prevents accidental exposure of secrets in logs, debug output, and error messages while maintaining code clarity with explicit `expose_secret()` calls. ## Changes ### Core Implementation - Added `secrecy` crate dependency (v0.10.3) - Created `src/shared/secrets/` module with: - `ApiToken` - wrapper for API tokens - `Password` - wrapper for passwords - `PlainApiToken` and `PlainPassword` type aliases for DTO boundaries ### Security Features - **Memory Protection**: Automatic memory zeroing when secrets are dropped - **Debug Redaction**: Secrets display as `"[REDACTED SecretString]"` in logs/debug - **Development Tracing**: Debug builds log secret access with caller location (TRACE level) - **Zero Production Cost**: Tracing completely removed in release builds via `#[cfg(debug_assertions)]` ### Integration Points - **Provider Config**: Updated Hetzner provider to use `ApiToken` - **Database Config**: Updated MySQL config to use `Password` - **JSON Schema**: Verified schema generation works correctly (18 tests passing) ### Documentation - Created comprehensive guide in `docs/contributing/secret-handling.md` - Updated `AGENTS.md` with Rule 18 for future development - Added ADR documenting the decision and rationale ## Testing - All 1538 unit tests passing - Added 18+ new tests for secret type behavior - E2E tests verified (infrastructure lifecycle + deployment workflow) - JSON schema generation confirmed working ## Breaking Changes ⚠️ **API Changes**: - `HetznerConfig::api_token()` now returns `&ApiToken` instead of `&String` - `DatabaseConfig::password()` now returns `&Password` instead of `&String` - Callers must use `.expose_secret()` to access the actual string values ## Migration Guide Replace direct string access with `expose_secret()`: ```rust // Before let token = config.api_token(); // After let token = config.api_token().expose_secret(); ``` ## Related - Closes #243 - Implements proposals from `docs/refactors/plans/secret-type-introduction.md` (100% complete) ## Checklist - [x] All tests pass - [x] Documentation updated - [x] Pre-commit checks pass - [x] ADR created - [x] Breaking changes documented ACKs for top commit: josecelano: ACK fa24387 Tree-SHA512: ccfc0d2dd41f5b43b7b9c0fbc03d715b37b95e0b192ca9ed842af4b76e7e8af6c7ca0df8f7d4fb41918fa432618bf58ce1c4296f9ee5fbb1fbb1abcfb32bd76a
2 parents 174eebc + fa24387 commit e114867

File tree

31 files changed

+891
-100
lines changed

31 files changed

+891
-100
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ These principles should guide all development decisions, code reviews, and featu
148148

149149
17. **When writing unit tests** (CRITICAL for test quality): Read [`docs/contributing/testing/unit-testing.md`](docs/contributing/testing/unit-testing.md) and follow the behavior-driven naming convention. **NEVER use the `test_` prefix** for test function names. Always use the `it_should_{expected_behavior}_when_{condition}` or `it_should_{expected_behavior}_given_{state}` pattern. This ensures tests clearly document the behavior being validated and the conditions under which it occurs. Example: `it_should_return_error_when_username_is_invalid()` instead of `test_invalid_username()`. Test names should follow the three-part structure (What-When-Then) and be descriptive enough that the test's purpose is clear without reading the code.
150150

151+
18. **When handling sensitive data (secrets)** (CRITICAL for security): Read [`docs/contributing/secret-handling.md`](docs/contributing/secret-handling.md) for the complete guide. **NEVER use `String` for sensitive data like API tokens, passwords, private keys, or database credentials**. Always use wrapper types from `src/shared/secrets/`: `ApiToken` for API tokens, `Password` for passwords, and their plain type aliases (`PlainApiToken`/`PlainPassword`) at DTO boundaries. Call `.expose_secret()` only when the actual value is needed. See the [ADR](docs/decisions/secrecy-crate-for-sensitive-data.md) for architectural rationale.
152+
151153
## 🧪 Build & Test
152154

153155
- **Setup Dependencies**: `cargo run --bin dependency-installer install` (sets up required development tools)

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ parking_lot = "0.12"
5151
reqwest = "0.12"
5252
rust-embed = "8.0"
5353
schemars = "1.1"
54+
secrecy = { version = "0.10", features = [ "serde" ] }
5455
serde = { version = "1.0", features = [ "derive" ] }
5556
serde_json = "1.0"
5657
tempfile = "3.0"

docs/contributing/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This guide will help you understand our development practices and contribution w
1515
| Module organization and imports | [module-organization.md](./module-organization.md) |
1616
| Error handling principles | [error-handling.md](./error-handling.md) |
1717
| Output handling with UserOutput | [output-handling.md](./output-handling.md) |
18+
| Secret handling (sensitive data) | [secret-handling.md](./secret-handling.md) |
1819
| Working with Tera templates | [templates.md](./templates.md) |
1920
| Environment variables naming | [environment-variables-naming.md](./environment-variables-naming.md) |
2021
| Debugging techniques | [debugging.md](./debugging.md) |
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
# Secret Handling Guide
2+
3+
This guide explains how to properly handle sensitive data (secrets) in the codebase to prevent accidental exposure through logs, debug output, or error messages.
4+
5+
## 📋 Quick Reference
6+
7+
**Never use `String` for sensitive data.** Always use wrapper types from `src/shared/secrets/`:
8+
9+
- **API Tokens**: `ApiToken` (domain/infrastructure), `PlainApiToken` (DTO boundaries)
10+
- **Passwords**: `Password` (domain/infrastructure), `PlainPassword` (DTO boundaries)
11+
12+
## 🔐 Why Secret Types?
13+
14+
### Security Risks with Plain Strings
15+
16+
Using plain `String` for secrets exposes them in multiple ways:
17+
18+
```rust
19+
// ❌ WRONG - Secret exposed everywhere
20+
pub struct HetznerConfig {
21+
pub api_token: String, // Visible in Debug, logs, error messages
22+
}
23+
24+
let config = HetznerConfig {
25+
api_token: "hetzner_abc123def456".to_string(),
26+
};
27+
28+
println!("{:?}", config);
29+
// Output: HetznerConfig { api_token: "hetzner_abc123def456" }
30+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
31+
// SECRET LEAKED!
32+
```
33+
34+
### Protection with Secret Types
35+
36+
Secret types prevent exposure:
37+
38+
```rust
39+
// ✅ CORRECT - Secret protected
40+
use crate::shared::secrets::ApiToken;
41+
42+
pub struct HetznerConfig {
43+
pub api_token: ApiToken, // Redacted in Debug output
44+
}
45+
46+
let config = HetznerConfig {
47+
api_token: ApiToken::from("hetzner_abc123def456"),
48+
};
49+
50+
println!("{:?}", config);
51+
// Output: HetznerConfig { api_token: Secret([REDACTED]) }
52+
// ^^^^^^^^^^^^^^^^^^^
53+
// SECRET PROTECTED!
54+
```
55+
56+
## 🎯 Secret Types Reference
57+
58+
### ApiToken
59+
60+
Use for API authentication tokens (REST APIs, cloud providers, etc.).
61+
62+
**Common Fields**:
63+
64+
- `HetznerConfig.api_token` - Cloud provider API token
65+
- HTTP API admin tokens
66+
- Third-party service API keys
67+
68+
**Example**:
69+
70+
```rust
71+
use crate::shared::secrets::ApiToken;
72+
use secrecy::ExposeSecret;
73+
74+
// Creating
75+
let token = ApiToken::from("my-api-token-123");
76+
77+
// Using (only when needed for actual API calls)
78+
let token_str: &str = token.expose_secret();
79+
```
80+
81+
### Password
82+
83+
Use for authentication passwords (database, SSH, services).
84+
85+
**Common Fields**:
86+
87+
- `MysqlConfig.password` - Database password
88+
- SSH passwords
89+
- Service account passwords
90+
91+
**Example**:
92+
93+
```rust
94+
use crate::shared::secrets::Password;
95+
use secrecy::ExposeSecret;
96+
97+
// Creating
98+
let password = Password::from("secure_password");
99+
100+
// Using (only when building connection strings)
101+
let password_str: &str = password.expose_secret();
102+
```
103+
104+
### PlainApiToken & PlainPassword
105+
106+
Type aliases for `String` used at DTO boundaries to mark temporarily transparent secrets.
107+
108+
**When to Use**: Configuration DTOs that accept user input before converting to secure types.
109+
110+
**Example**:
111+
112+
```rust
113+
use crate::shared::secrets::{PlainApiToken, ApiToken};
114+
115+
// DTO layer (accepts plain string from user)
116+
#[derive(Deserialize)]
117+
pub struct HetznerProviderSection {
118+
pub api_token: PlainApiToken, // Type alias signals "this becomes secret"
119+
}
120+
121+
// Domain layer (secure type)
122+
pub struct HetznerConfig {
123+
pub api_token: ApiToken, // Actual secret protection
124+
}
125+
126+
// Conversion
127+
impl HetznerProviderSection {
128+
pub fn to_domain(&self) -> HetznerConfig {
129+
HetznerConfig {
130+
api_token: ApiToken::from(self.api_token.clone()), // String → ApiToken
131+
}
132+
}
133+
}
134+
```
135+
136+
## 🔄 Secret Lifecycle Pattern
137+
138+
Secrets follow this flow through application layers:
139+
140+
```text
141+
User Input (PlainApiToken/String)
142+
↓ Deserialization
143+
DTO Layer (PlainApiToken for clarity)
144+
↓ Conversion
145+
Domain Layer (ApiToken for security)
146+
↓ Pass through
147+
Infrastructure Layer (ApiToken.expose_secret() only when needed)
148+
↓ Use in external calls
149+
External API/Database
150+
```
151+
152+
## 📝 Usage Guidelines
153+
154+
### ✅ DO
155+
156+
- **Use secret types for all sensitive data**:
157+
158+
```rust
159+
pub struct MysqlConfig {
160+
pub password: Password, // ✅ Protected
161+
}
162+
```
163+
164+
- **Use `PlainApiToken`/`PlainPassword` at DTO boundaries**:
165+
166+
```rust
167+
#[derive(Deserialize)]
168+
pub struct ConfigDto {
169+
pub api_token: PlainApiToken, // ✅ Clearly marked as temporary
170+
}
171+
```
172+
173+
- **Call `.expose_secret()` only when needed**:
174+
175+
```rust
176+
fn connect_database(config: &MysqlConfig) -> Connection {
177+
let password = config.password.expose_secret(); // ✅ Only here
178+
Connection::new(&config.host, password)
179+
}
180+
```
181+
182+
- **Document why secrets are exposed**:
183+
184+
```rust
185+
// ✅ Clear reasoning
186+
fn build_connection_string(config: &MysqlConfig) -> String {
187+
// Expose password only for connection string construction
188+
let password = config.password.expose_secret();
189+
format!("mysql://{}:{}@{}", config.username, password, config.host)
190+
}
191+
```
192+
193+
### ❌ DON'T
194+
195+
- **Never use `String` for secrets**:
196+
197+
```rust
198+
pub struct Config {
199+
pub api_token: String, // ❌ Will leak in debug output
200+
}
201+
```
202+
203+
- **Never log or print secrets**:
204+
205+
```rust
206+
let token = config.api_token.expose_secret();
207+
println!("Token: {}", token); // ❌ NEVER DO THIS
208+
tracing::info!("Using token: {}", token); // ❌ NEVER DO THIS
209+
```
210+
211+
- **Never include secrets in error messages**:
212+
213+
```rust
214+
// ❌ BAD
215+
return Err(format!("Invalid token: {}", token.expose_secret()));
216+
217+
// ✅ GOOD
218+
return Err("Invalid token format".to_string());
219+
```
220+
221+
- **Never store exposed secrets**:
222+
223+
```rust
224+
// ❌ BAD - Defeats the purpose
225+
let exposed_token = config.api_token.expose_secret().to_string();
226+
227+
// ✅ GOOD - Keep as secret type
228+
let token = &config.api_token;
229+
```
230+
231+
## 🧪 Testing with Secrets
232+
233+
### Test Data
234+
235+
Use `.from()` for test secrets:
236+
237+
```rust
238+
#[cfg(test)]
239+
mod tests {
240+
use super::*;
241+
use crate::shared::secrets::ApiToken;
242+
243+
#[test]
244+
fn it_should_create_config_with_api_token() {
245+
let config = HetznerConfig {
246+
api_token: ApiToken::from("test-token-123"),
247+
server_type: "cx22".to_string(),
248+
// ...
249+
};
250+
251+
// Secrets are automatically redacted in test failures
252+
assert_eq!(config.server_type, "cx22");
253+
}
254+
}
255+
```
256+
257+
### Comparing Secrets
258+
259+
Secret types implement `PartialEq` and `Eq`:
260+
261+
```rust
262+
#[test]
263+
fn it_should_compare_secrets() {
264+
let token1 = ApiToken::from("same-token");
265+
let token2 = ApiToken::from("same-token");
266+
let token3 = ApiToken::from("different-token");
267+
268+
assert_eq!(token1, token2); // ✅ Works
269+
assert_ne!(token1, token3); // ✅ Works
270+
}
271+
```
272+
273+
## 🔍 Identifying Secret Fields
274+
275+
Ask these questions:
276+
277+
1. **Would it be a security issue if this appeared in logs?** → Use secret type
278+
2. **Is this used for authentication/authorization?** → Use secret type
279+
3. **Should this be redacted in debug output?** → Use secret type
280+
4. **Does this access protected resources?** → Use secret type
281+
282+
**Common Secret Patterns**:
283+
284+
- Anything named `*_token`, `*_password`, `*_secret`, `*_key`
285+
- Database credentials
286+
- API keys and tokens
287+
- Private keys
288+
- Authentication credentials
289+
290+
## 🛠️ Implementation Checklist
291+
292+
When adding a new secret field:
293+
294+
- [ ] Choose correct type (`ApiToken` for tokens, `Password` for passwords)
295+
- [ ] Use `Plain*` type alias at DTO boundaries if accepting user input
296+
- [ ] Update struct field to use secret type
297+
- [ ] Add `use crate::shared::secrets::{ApiToken, PlainApiToken};` import
298+
- [ ] Update construction sites to use `.from()`
299+
- [ ] Update usage sites to call `.expose_secret()` only when necessary
300+
- [ ] Verify debug output redacts the secret
301+
- [ ] Update all tests to use secret types
302+
- [ ] Check serialization/deserialization works correctly
303+
- [ ] Document why `.expose_secret()` is called at each location
304+
305+
## 🔗 Related Documentation
306+
307+
- **Architecture Decision**: [`docs/decisions/secrecy-crate-for-sensitive-data.md`](../decisions/secrecy-crate-for-sensitive-data.md) - Why we use the secrecy crate
308+
- **Implementation Details**: `src/shared/secrets/` - Source code for secret types
309+
- **Refactor Plan**: [`docs/refactors/plans/secret-type-introduction.md`](../refactors/plans/secret-type-introduction.md) - Migration tracking
310+
311+
## 🔐 Security Features
312+
313+
Secret types provide multiple layers of protection:
314+
315+
1. **Debug Redaction**: `Debug` output shows `Secret([REDACTED])` instead of actual value
316+
2. **Memory Zeroing**: Secrets are wiped from memory when dropped (via `zeroize` crate)
317+
3. **Explicit Access**: `.expose_secret()` makes secret usage visible in code
318+
4. **Type Safety**: Compiler enforces correct handling
319+
5. **Serialization Control**: Can serialize when needed (config files) but remains protected in memory
320+
321+
## ❓ FAQ
322+
323+
### Why not just be careful with logging?
324+
325+
Human error is inevitable. Type-level protection prevents mistakes at compile time.
326+
327+
### Can I serialize secrets?
328+
329+
Yes, secret types implement `Serialize`/`Deserialize` for configuration files. However, they remain protected in memory and debug output.
330+
331+
### What if I need the secret value?
332+
333+
Call `.expose_secret()` - but document why it's necessary. This makes secret usage explicit and auditable.
334+
335+
### Do I need to wrap everything?
336+
337+
No, only sensitive data. Regular strings for non-secret configuration values are fine.
338+
339+
### What about plain types (PlainApiToken)?
340+
341+
Use them at DTO boundaries to clearly mark "this will become a secret." They're just type aliases for `String` but signal intent.

0 commit comments

Comments
 (0)