sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "dirigent_auth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
uuid = { version = "1.0", features = ["js", "serde", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Account model for Dirigent identity management.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{secret::SecretSource, UserId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountKind {
|
||||
Local,
|
||||
Matrix,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AccountProfile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: AccountKind,
|
||||
|
||||
#[serde(skip)]
|
||||
pub config_name: String,
|
||||
|
||||
#[serde(skip)]
|
||||
pub user_id: Option<UserId>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub credentials: HashMap<String, SecretSource>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub profile: AccountProfile,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn resolve_credential(&self, name: &str) -> Result<String, crate::SecretError> {
|
||||
self.credentials
|
||||
.get(name)
|
||||
.ok_or_else(|| crate::SecretError::EnvNotSet {
|
||||
key: format!("<credential '{}' not configured>", name),
|
||||
})
|
||||
.and_then(|s| s.resolve())
|
||||
}
|
||||
|
||||
pub fn property_str(&self, key: &str) -> Option<&str> {
|
||||
self.properties.get(key).and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
pub fn property_str_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
|
||||
self.property_str(key).unwrap_or(default)
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.profile.display_name.as_deref()
|
||||
.or(self.profile.name.as_deref())
|
||||
.or(self.profile.username.as_deref())
|
||||
.unwrap_or(&self.config_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_local() -> Account {
|
||||
Account {
|
||||
kind: AccountKind::Local,
|
||||
config_name: "local".to_string(),
|
||||
user_id: None,
|
||||
credentials: HashMap::new(),
|
||||
profile: AccountProfile { name: Some("Gabriel".to_string()), ..Default::default() },
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_matrix() -> Account {
|
||||
let mut creds = HashMap::new();
|
||||
creds.insert("password".to_string(), SecretSource::Inline { value: "bot_pass".to_string() });
|
||||
let mut props = HashMap::new();
|
||||
props.insert("homeserver".to_string(), serde_json::json!("https://matrix.example.com"));
|
||||
props.insert("device_id".to_string(), serde_json::json!("DIRIGENT_01"));
|
||||
Account {
|
||||
kind: AccountKind::Matrix,
|
||||
config_name: "matrix-bot".to_string(),
|
||||
user_id: None,
|
||||
credentials: creds,
|
||||
profile: AccountProfile {
|
||||
username: Some("dirigent_bot".to_string()),
|
||||
display_name: Some("Dirigent Bot".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
properties: props,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_display_name() {
|
||||
assert_eq!(sample_local().display_name(), "Gabriel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matrix_display_name() {
|
||||
assert_eq!(sample_matrix().display_name(), "Dirigent Bot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_to_config_name() {
|
||||
let acct = Account {
|
||||
kind: AccountKind::Local,
|
||||
config_name: "fallback".to_string(),
|
||||
user_id: None,
|
||||
credentials: HashMap::new(),
|
||||
profile: AccountProfile::default(),
|
||||
properties: HashMap::new(),
|
||||
};
|
||||
assert_eq!(acct.display_name(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_credential() {
|
||||
assert_eq!(sample_matrix().resolve_credential("password").unwrap(), "bot_pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_credential() {
|
||||
assert!(sample_local().resolve_credential("password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property_str() {
|
||||
let acct = sample_matrix();
|
||||
assert_eq!(acct.property_str("homeserver"), Some("https://matrix.example.com"));
|
||||
assert_eq!(acct.property_str("nonexistent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property_str_or() {
|
||||
let acct = sample_matrix();
|
||||
assert_eq!(acct.property_str_or("device_id", "DEFAULT"), "DIRIGENT_01");
|
||||
assert_eq!(acct.property_str_or("missing", "DEFAULT"), "DEFAULT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_account_kind_serde() {
|
||||
let json = serde_json::to_string(&AccountKind::Matrix).unwrap();
|
||||
assert_eq!(json, r#""matrix""#);
|
||||
let back: AccountKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, AccountKind::Matrix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_account_serde_roundtrip() {
|
||||
let acct = sample_matrix();
|
||||
let json = serde_json::to_string(&acct).unwrap();
|
||||
let back: Account = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.kind, AccountKind::Matrix);
|
||||
assert_eq!(back.profile.display_name, Some("Dirigent Bot".to_string()));
|
||||
assert!(back.credentials.contains_key("password"));
|
||||
assert_eq!(back.property_str("homeserver"), Some("https://matrix.example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Dirigent Auth
|
||||
//!
|
||||
//! Tiny user identity crate for the Dirigent system. No async deps.
|
||||
//! Usable from both server and WASM targets.
|
||||
//!
|
||||
//! Provides the core `UserId`, `User`, and `UserProfile` types used
|
||||
//! throughout the Dirigent ecosystem for ownership tracking and
|
||||
//! authorization.
|
||||
|
||||
pub mod secret;
|
||||
pub use secret::{SecretSource, SecretError};
|
||||
|
||||
pub mod account;
|
||||
pub use account::{Account, AccountKind, AccountProfile};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for a user.
|
||||
///
|
||||
/// Uses UUID v7 (time-ordered) for new users. Existing string-based
|
||||
/// IDs should be migrated to UUIDs at creation time.
|
||||
pub type UserId = Uuid;
|
||||
|
||||
/// User information.
|
||||
///
|
||||
/// Represents a user in the Dirigent system with profile data and
|
||||
/// creation timestamp.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
/// Unique user identifier (UUID v7)
|
||||
pub id: UserId,
|
||||
/// User profile with optional display fields
|
||||
pub profile: UserProfile,
|
||||
/// When this user was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new user with a generated UUID v7 and the given profile.
|
||||
pub fn new(profile: UserProfile) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
profile,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new user with a specific ID.
|
||||
pub fn with_id(id: UserId, profile: UserProfile) -> Self {
|
||||
Self {
|
||||
id,
|
||||
profile,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display name, falling back to username or "Unknown".
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.profile
|
||||
.name
|
||||
.as_deref()
|
||||
.or(self.profile.username.as_deref())
|
||||
.unwrap_or("Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
/// User profile information.
|
||||
///
|
||||
/// All fields are optional to allow progressive enrichment.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
/// Display name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Email address
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
/// Username / handle
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_user_creation() {
|
||||
let user = User::new(UserProfile {
|
||||
name: Some("Test User".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(user.display_name(), "Test User");
|
||||
assert_eq!(user.id.get_version_num(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_with_id() {
|
||||
let id = Uuid::nil();
|
||||
let user = User::with_id(
|
||||
id,
|
||||
UserProfile {
|
||||
name: Some("Nil User".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(user.id, Uuid::nil());
|
||||
assert_eq!(user.display_name(), "Nil User");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_fallbacks() {
|
||||
// Falls back to username
|
||||
let user = User::new(UserProfile {
|
||||
username: Some("jdoe".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(user.display_name(), "jdoe");
|
||||
|
||||
// Falls back to "Unknown"
|
||||
let user = User::new(UserProfile::default());
|
||||
assert_eq!(user.display_name(), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_roundtrip() {
|
||||
let user = User::new(UserProfile {
|
||||
name: Some("Test".to_string()),
|
||||
email: Some("test@example.com".to_string()),
|
||||
username: None,
|
||||
});
|
||||
|
||||
let json = serde_json::to_string(&user).expect("serialize");
|
||||
let deserialized: User = serde_json::from_str(&json).expect("deserialize");
|
||||
|
||||
assert_eq!(deserialized.id, user.id);
|
||||
assert_eq!(deserialized.profile.name, user.profile.name);
|
||||
assert_eq!(deserialized.profile.email, user.profile.email);
|
||||
assert!(deserialized.profile.username.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_skip_none_fields() {
|
||||
let profile = UserProfile {
|
||||
name: Some("Test".to_string()),
|
||||
email: None,
|
||||
username: None,
|
||||
};
|
||||
let json = serde_json::to_string(&profile).expect("serialize");
|
||||
assert!(!json.contains("email"));
|
||||
assert!(!json.contains("username"));
|
||||
assert!(json.contains("name"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Credential resolution for Dirigent accounts.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretError {
|
||||
#[error("Environment variable '{key}' not set")]
|
||||
EnvNotSet { key: String },
|
||||
#[error("Failed to read secret file '{path}': {reason}")]
|
||||
FileReadFailed { path: String, reason: String },
|
||||
}
|
||||
|
||||
/// Describes how to retrieve a secret value (password, token, etc.).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "source")]
|
||||
pub enum SecretSource {
|
||||
#[serde(rename = "env")]
|
||||
Env { key: String },
|
||||
#[serde(rename = "inline")]
|
||||
Inline { value: String },
|
||||
#[serde(rename = "file")]
|
||||
File { path: String },
|
||||
}
|
||||
|
||||
impl SecretSource {
|
||||
pub fn resolve(&self) -> Result<String, SecretError> {
|
||||
match self {
|
||||
SecretSource::Env { key } => {
|
||||
std::env::var(key).map_err(|_| SecretError::EnvNotSet { key: key.clone() })
|
||||
}
|
||||
SecretSource::Inline { value } => Ok(value.clone()),
|
||||
SecretSource::File { path } => std::fs::read_to_string(path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|e| SecretError::FileReadFailed {
|
||||
path: path.clone(),
|
||||
reason: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inline_resolve() {
|
||||
let src = SecretSource::Inline { value: "hunter2".to_string() };
|
||||
assert_eq!(src.resolve().unwrap(), "hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_resolve() {
|
||||
std::env::set_var("DIRIGENT_TEST_SECRET_7382", "env_value");
|
||||
let src = SecretSource::Env { key: "DIRIGENT_TEST_SECRET_7382".to_string() };
|
||||
assert_eq!(src.resolve().unwrap(), "env_value");
|
||||
std::env::remove_var("DIRIGENT_TEST_SECRET_7382");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_missing() {
|
||||
let src = SecretSource::Env { key: "DIRIGENT_NONEXISTENT_VAR_99999".to_string() };
|
||||
assert!(src.resolve().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_missing() {
|
||||
let src = SecretSource::File { path: "/tmp/dirigent_nonexistent_secret_file".to_string() };
|
||||
assert!(src.resolve().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_env() {
|
||||
let src = SecretSource::Env { key: "MY_VAR".to_string() };
|
||||
let json = serde_json::to_string(&src).unwrap();
|
||||
assert!(json.contains(r#""source":"env"#));
|
||||
let back: SecretSource = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, SecretSource::Env { key } if key == "MY_VAR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_inline() {
|
||||
let src = SecretSource::Inline { value: "secret".to_string() };
|
||||
let json = serde_json::to_string(&src).unwrap();
|
||||
let back: SecretSource = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, SecretSource::Inline { value } if value == "secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_file() {
|
||||
let src = SecretSource::File { path: "/run/secrets/pw".to_string() };
|
||||
let json = serde_json::to_string(&src).unwrap();
|
||||
assert!(json.contains(r#""source":"file"#));
|
||||
let back: SecretSource = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, SecretSource::File { path } if path == "/run/secrets/pw"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user