sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+13
View File
@@ -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]
+173
View File
@@ -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"));
}
}
+156
View File
@@ -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"));
}
}
+98
View File
@@ -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"));
}
}