Skip to main content

termivar_scanner/
auth.rs

1//! Identity metadata records.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** host/library records only; no repository runtime caller.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental/scaffold.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! This module contains non-secret identity metadata. It deliberately does not
13//! accept, issue, store, validate, serialize, or debug-print passwords, API
14//! keys, session tokens, or other credentials. Authentication and permission
15//! enforcement belong to an integrating host.
16
17use serde::{Deserialize, Serialize};
18
19/// Role label supplied by an integrating host.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum UserRole {
22    #[serde(rename = "admin")]
23    Admin,
24    #[serde(rename = "analyst")]
25    Analyst,
26    #[serde(rename = "viewer")]
27    Viewer,
28    #[serde(rename = "api_only")]
29    ApiOnly,
30}
31
32impl UserRole {
33    #[must_use]
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Admin => "admin",
37            Self::Analyst => "analyst",
38            Self::Viewer => "viewer",
39            Self::ApiOnly => "api_only",
40        }
41    }
42}
43
44/// Non-secret user metadata.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct User {
48    pub user_id: String,
49    pub username: String,
50    pub email: String,
51    pub role: UserRole,
52    pub created_at: u64,
53    pub last_login: Option<u64>,
54    pub active: bool,
55}
56
57impl User {
58    /// Creates a metadata record with a locally unique identifier.
59    #[must_use]
60    pub fn new(username: String, email: String, role: UserRole) -> Self {
61        Self {
62            user_id: uuid::Uuid::new_v4().to_string(),
63            username,
64            email,
65            role,
66            created_at: std::time::SystemTime::now()
67                .duration_since(std::time::UNIX_EPOCH)
68                .unwrap_or_default()
69                .as_secs(),
70            last_login: None,
71            active: true,
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use serde_json::json;
80
81    #[test]
82    fn role_names_are_metadata_not_permission_decisions() {
83        assert_eq!(UserRole::Admin.as_str(), "admin");
84        assert_eq!(UserRole::Analyst.as_str(), "analyst");
85        assert_eq!(UserRole::Viewer.as_str(), "viewer");
86        assert_eq!(UserRole::ApiOnly.as_str(), "api_only");
87    }
88
89    #[test]
90    fn user_serialization_contains_no_credential_fields() {
91        let user = User::new(
92            "analyst".to_owned(),
93            "analyst@example.test".to_owned(),
94            UserRole::Analyst,
95        );
96
97        let encoded = serde_json::to_value(&user).unwrap();
98        let object = encoded.as_object().unwrap();
99        for forbidden in ["api_key", "password", "token", "secret"] {
100            assert!(!object.contains_key(forbidden));
101        }
102
103        let debug = format!("{user:?}");
104        assert!(!debug.contains("credential-canary"));
105    }
106
107    #[test]
108    fn legacy_secret_bearing_user_shape_is_rejected() {
109        let encoded = json!({
110            "user_id": "user-1",
111            "username": "analyst",
112            "email": "analyst@example.test",
113            "role": "analyst",
114            "api_key": "credential-canary",
115            "created_at": 1,
116            "last_login": null,
117            "active": true
118        });
119
120        assert!(serde_json::from_value::<User>(encoded).is_err());
121    }
122}