Skip to main content

perfgate_server/
credential_source.rs

1use chrono::{DateTime, Utc};
2use serde::Deserialize;
3use std::path::PathBuf;
4use std::process::Command;
5
6use crate::auth::Role;
7
8fn default_project() -> String {
9    "default".to_string()
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CredentialSource {
14    Env { var: String },
15    File { path: PathBuf },
16    Command { command: String },
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct KeyPolicy {
21    pub id: String,
22    pub role: Role,
23    pub project: String,
24    pub benchmark_regex: Option<String>,
25    pub expires_at: Option<DateTime<Utc>>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LoadedCredential {
30    pub policy: KeyPolicy,
31    pub secret: String,
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum CredentialSourceError {
36    #[error("missing environment variable {0}")]
37    MissingEnvVar(String),
38
39    #[error("failed to read key material from {source_name}: {message}")]
40    ReadFailure {
41        source_name: String,
42        message: String,
43    },
44
45    #[error("command source failed (status {status})")]
46    CommandFailure { status: i32 },
47
48    #[error("key material document parse failed: {0}")]
49    ParseFailure(String),
50
51    #[error("invalid key policy document: {0}")]
52    InvalidDocument(String),
53}
54
55#[derive(Debug, Clone, Deserialize)]
56struct RawCredential {
57    id: String,
58    role: Role,
59    #[serde(default = "default_project")]
60    project: String,
61    #[serde(default)]
62    benchmark_regex: Option<String>,
63    #[serde(default)]
64    expires_at: Option<DateTime<Utc>>,
65    secret: String,
66}
67
68#[derive(Debug, Deserialize)]
69struct JsonDocument {
70    #[serde(default)]
71    keys: Vec<RawCredential>,
72    #[serde(default)]
73    api_keys: Vec<RawCredential>,
74}
75
76#[derive(Debug, Deserialize)]
77struct TomlDocument {
78    #[serde(default)]
79    keys: Vec<RawCredential>,
80    #[serde(default)]
81    api_keys: Vec<RawCredential>,
82}
83
84impl CredentialSource {
85    pub fn load(&self) -> Result<Vec<LoadedCredential>, CredentialSourceError> {
86        let content = match self {
87            Self::Env { var } => {
88                std::env::var(var).map_err(|_| CredentialSourceError::MissingEnvVar(var.clone()))?
89            }
90            Self::File { path } => {
91                std::fs::read_to_string(path).map_err(|e| CredentialSourceError::ReadFailure {
92                    source_name: format!("file {}", path.display()),
93                    message: e.to_string(),
94                })?
95            }
96            Self::Command { command } => run_command(command)?,
97        };
98
99        parse_credentials_document(&content)
100    }
101}
102
103fn run_command(command: &str) -> Result<String, CredentialSourceError> {
104    if command.trim().is_empty() {
105        return Err(CredentialSourceError::ReadFailure {
106            source_name: "command".to_string(),
107            message: "command is empty".to_string(),
108        });
109    }
110
111    let output =
112        shell_command(command)
113            .output()
114            .map_err(|e| CredentialSourceError::ReadFailure {
115                source_name: "command".to_string(),
116                message: e.to_string(),
117            })?;
118
119    if !output.status.success() {
120        let status = output.status.code().unwrap_or(-1);
121        return Err(CredentialSourceError::CommandFailure { status });
122    }
123
124    Ok(String::from_utf8_lossy(&output.stdout).to_string())
125}
126
127#[cfg(windows)]
128fn shell_command(command: &str) -> Command {
129    let mut cmd = Command::new("powershell");
130    cmd.arg("-NoProfile").arg("-Command").arg(command);
131    cmd
132}
133
134#[cfg(not(windows))]
135fn shell_command(command: &str) -> Command {
136    let mut cmd = Command::new("sh");
137    cmd.arg("-c").arg(command);
138    cmd
139}
140
141fn unwrap_wrapper_credentials(
142    keys: Vec<RawCredential>,
143    api_keys: Vec<RawCredential>,
144) -> Result<Vec<RawCredential>, CredentialSourceError> {
145    match (keys.is_empty(), api_keys.is_empty()) {
146        (false, true) => Ok(keys),
147        (true, false) => Ok(api_keys),
148        (false, false) => Err(CredentialSourceError::InvalidDocument(
149            "document must not contain both 'keys' and 'api_keys'".to_string(),
150        )),
151        (true, true) => Err(CredentialSourceError::InvalidDocument(
152            "document must contain either 'keys' or 'api_keys'".to_string(),
153        )),
154    }
155}
156
157fn validate_loaded_credentials(
158    parsed: Vec<RawCredential>,
159) -> Result<Vec<LoadedCredential>, CredentialSourceError> {
160    let mut out = Vec::with_capacity(parsed.len());
161    for entry in parsed {
162        if entry.id.trim().is_empty() {
163            return Err(CredentialSourceError::InvalidDocument(
164                "entry id must not be empty".to_string(),
165            ));
166        }
167        if entry.secret.trim().is_empty() {
168            return Err(CredentialSourceError::InvalidDocument(format!(
169                "entry '{}' has empty secret",
170                entry.id
171            )));
172        }
173
174        out.push(LoadedCredential {
175            policy: KeyPolicy {
176                id: entry.id,
177                role: entry.role,
178                project: entry.project,
179                benchmark_regex: entry.benchmark_regex,
180                expires_at: entry.expires_at,
181            },
182            secret: entry.secret,
183        });
184    }
185
186    Ok(out)
187}
188
189pub fn parse_credentials_document(
190    raw: &str,
191) -> Result<Vec<LoadedCredential>, CredentialSourceError> {
192    let trimmed = raw.trim();
193    if trimmed.is_empty() {
194        return Ok(Vec::new());
195    }
196
197    let mut parse_errors = Vec::new();
198
199    match serde_json::from_str::<Vec<RawCredential>>(trimmed) {
200        Ok(parsed) => return validate_loaded_credentials(parsed),
201        Err(err) => parse_errors.push(err.to_string()),
202    }
203
204    match serde_json::from_str::<JsonDocument>(trimmed) {
205        Ok(doc) => {
206            return validate_loaded_credentials(unwrap_wrapper_credentials(
207                doc.keys,
208                doc.api_keys,
209            )?);
210        }
211        Err(err) => parse_errors.push(err.to_string()),
212    }
213
214    match toml::from_str::<Vec<RawCredential>>(trimmed) {
215        Ok(parsed) => return validate_loaded_credentials(parsed),
216        Err(err) => parse_errors.push(err.to_string()),
217    }
218
219    match toml::from_str::<TomlDocument>(trimmed) {
220        Ok(doc) => {
221            return validate_loaded_credentials(unwrap_wrapper_credentials(
222                doc.keys,
223                doc.api_keys,
224            )?);
225        }
226        Err(err) => parse_errors.push(err.to_string()),
227    }
228
229    Err(CredentialSourceError::ParseFailure(parse_errors.join("; ")))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::path::Path;
236
237    #[cfg(windows)]
238    fn read_file_command(path: &Path) -> String {
239        let path = path.display().to_string().replace('\'', "''");
240        format!("Get-Content -Raw -LiteralPath '{}'", path)
241    }
242
243    #[cfg(not(windows))]
244    fn read_file_command(path: &Path) -> String {
245        format!("cat \"{}\"", path.display())
246    }
247
248    #[cfg(windows)]
249    fn failing_command() -> String {
250        "Write-Error 'boom'; exit 9".to_string()
251    }
252
253    #[cfg(not(windows))]
254    fn failing_command() -> String {
255        "echo boom >&2; exit 9".to_string()
256    }
257
258    #[test]
259    fn parse_json_credentials() {
260        let doc = r#"[
261          {
262            "id":"ci-promoter",
263            "role":"promoter",
264            "project":"my-project",
265            "benchmark_regex":".*",
266            "secret":"pg_live_abcdefghijklmnopqrstuvwxyz123456"
267          }
268        ]"#;
269
270        let creds = parse_credentials_document(doc).unwrap();
271        assert_eq!(creds.len(), 1);
272        assert_eq!(creds[0].policy.id, "ci-promoter");
273        assert_eq!(creds[0].policy.role, Role::Promoter);
274    }
275
276    #[test]
277    fn parse_toml_credentials_table() {
278        let doc = r#"
279          [[keys]]
280          id = "dev"
281          role = "admin"
282          project = "default"
283          secret = "pg_test_abcdefghijklmnopqrstuvwxyz123456"
284        "#;
285
286        let creds = parse_credentials_document(doc).unwrap();
287        assert_eq!(creds.len(), 1);
288        assert_eq!(creds[0].policy.role, Role::Admin);
289    }
290
291    #[test]
292    fn parse_json_credentials_object_wrapper() {
293        let doc = r#"{
294          "keys": [
295            {
296              "id":"ci-promoter",
297              "role":"promoter",
298              "project":"my-project",
299              "secret":"pg_live_abcdefghijklmnopqrstuvwxyz123456"
300            }
301          ]
302        }"#;
303
304        let creds = parse_credentials_document(doc).unwrap();
305        assert_eq!(creds.len(), 1);
306        assert_eq!(creds[0].policy.id, "ci-promoter");
307        assert_eq!(creds[0].policy.role, Role::Promoter);
308    }
309
310    #[test]
311    fn parse_json_credentials_wrapper_rejects_missing_expected_fields() {
312        let doc = r#"{
313          "apiKeys": [
314            {
315              "id":"ci-promoter",
316              "role":"promoter",
317              "project":"my-project",
318              "secret":"pg_live_abcdefghijklmnopqrstuvwxyz123456"
319            }
320          ]
321        }"#;
322
323        let err = parse_credentials_document(doc).unwrap_err().to_string();
324        assert!(err.contains("document must contain either 'keys' or 'api_keys'"));
325    }
326
327    #[test]
328    fn parse_toml_credentials_wrapper_rejects_duplicate_wrappers() {
329        let doc = r#"
330          [[keys]]
331          id = "dev"
332          role = "admin"
333          project = "default"
334          secret = "pg_test_abcdefghijklmnopqrstuvwxyz123456"
335
336          [[api_keys]]
337          id = "ci"
338          role = "viewer"
339          project = "default"
340          secret = "pg_test_abcdefghijklmnopqrstuvwxyz123456"
341        "#;
342
343        let err = parse_credentials_document(doc).unwrap_err().to_string();
344        assert!(err.contains("document must not contain both 'keys' and 'api_keys'"));
345    }
346
347    #[test]
348    fn command_source_success() {
349        let temp_dir = tempfile::tempdir().unwrap();
350        let path = temp_dir.path().join("keys.json");
351        std::fs::write(
352            &path,
353            "[{\"id\":\"k1\",\"role\":\"viewer\",\"project\":\"p\",\"secret\":\"pg_test_abcdefghijklmnopqrstuvwxyz123456\"}]",
354        )
355        .unwrap();
356        let src = CredentialSource::Command {
357            command: read_file_command(&path),
358        };
359        let creds = src.load().unwrap();
360        assert_eq!(creds.len(), 1);
361        assert_eq!(creds[0].policy.id, "k1");
362    }
363
364    #[test]
365    fn command_source_failure_hides_stderr() {
366        let src = CredentialSource::Command {
367            command: failing_command(),
368        };
369        let err = src.load().unwrap_err().to_string();
370        assert!(err.contains("status 9"));
371        assert!(!err.contains("boom"));
372    }
373
374    #[test]
375    fn file_source_loads_credentials() {
376        let temp = tempfile::NamedTempFile::new().unwrap();
377        std::fs::write(
378            temp.path(),
379            "[{\"id\":\"f1\",\"role\":\"viewer\",\"project\":\"x\",\"secret\":\"pg_test_abcdefghijklmnopqrstuvwxyz123456\"}]",
380        )
381        .unwrap();
382
383        let src = CredentialSource::File {
384            path: temp.path().to_path_buf(),
385        };
386        let creds = src.load().unwrap();
387        assert_eq!(creds[0].policy.id, "f1");
388    }
389
390    #[test]
391    #[allow(unsafe_code)]
392    fn env_source_loads_credentials() {
393        let var = "PERFGATE_AUTH_TEST_KEYS";
394        // SAFETY: test-only process-local environment mutation.
395        unsafe {
396            std::env::set_var(
397                var,
398                "[{\"id\":\"e1\",\"role\":\"viewer\",\"project\":\"x\",\"secret\":\"pg_test_abcdefghijklmnopqrstuvwxyz123456\"}]",
399            );
400        }
401
402        let src = CredentialSource::Env {
403            var: var.to_string(),
404        };
405        let creds = src.load().unwrap();
406        assert_eq!(creds[0].policy.id, "e1");
407    }
408}