[Add] Proper User managment

This commit is contained in:
Alex Emmet 2026-09-02 22:42:25 +02:00
commit b38b68ad96
No known key found for this signature in database
38 changed files with 4331 additions and 1065 deletions

View file

@ -4,16 +4,17 @@ pub mod transport;
pub use protocol::{
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase,
LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult,
SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary,
UpdateStatusResponse, UserDetailResponse, UserSummary,
ConfigResponse, ConnectionStatus, CredentialStatus, DaemonMessage, DaemonStatusResponse,
DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent,
LifecyclePhase, LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload,
ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind,
TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics,
UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary,
};
pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg};
/// Current IPC protocol version.
pub const PROTOCOL_VERSION: u16 = 2;
pub const PROTOCOL_VERSION: u16 = 4;
/// Minimum protocol version this daemon understands.
pub const MIN_PROTOCOL_VERSION: u16 = 2;

View file

@ -48,9 +48,34 @@ pub enum LocalRequest {
CreateUser {
username: String,
},
InspectTuCredential {
credential: SecretString,
},
AttachUserFromTu {
credential: SecretString,
},
ReconcileUser {
user_id: i64,
},
ForceDetachUser {
user_id: i64,
},
ForgetReleasedUser {
user_id: i64,
},
GetUserDiagnostics {
user_id: i64,
},
RevokeTrustedApp {
user_id: i64,
app_id: String,
},
RevokeAllTrustedApps {
user_id: i64,
},
ExportUserCredential {
user_id: i64,
},
PurgeUserData {
user_id: i64,
},
@ -127,6 +152,7 @@ impl LocalRequest {
| Self::GetOmikronStatus
| Self::ListComponents
| Self::GetUser { .. }
| Self::GetUserDiagnostics { .. }
| Self::GetLogs { .. }
| Self::CheckUpdate
| Self::ListCommunities => IpcRole::Read,
@ -134,7 +160,14 @@ impl LocalRequest {
Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate,
Self::CreateUser { .. }
| Self::InspectTuCredential { .. }
| Self::AttachUserFromTu { .. }
| Self::ReconcileUser { .. }
| Self::ForceDetachUser { .. }
| Self::ForgetReleasedUser { .. }
| Self::RevokeTrustedApp { .. }
| Self::RevokeAllTrustedApps { .. }
| Self::ExportUserCredential { .. }
| Self::PurgeUserData { .. }
| Self::ReleaseUser { .. }
| Self::CompleteDeleteUser { .. }
@ -220,6 +253,14 @@ pub enum ResponsePayload {
user_id: i64,
username: String,
},
TuCredentialPreview(TuCredentialPreview),
UserReconciled(UserReconcileResult),
UserDiagnostics(UserDiagnostics),
UserCredentialExport {
user_id: i64,
username: String,
credential: SecretString,
},
/// Retained only for wire compatibility. New lifecycle code never emits it.
UserRemoved {
user_id: i64,
@ -267,7 +308,7 @@ pub struct UserDetailResponse {
pub trusted_apps: Vec<String>,
pub state: LocalUserState,
pub data_present: bool,
pub credential_present: bool,
pub credential_status: CredentialStatus,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -304,7 +345,66 @@ pub struct UserSummary {
pub username: String,
pub state: LocalUserState,
pub data_present: bool,
pub credential_present: bool,
pub credential_status: CredentialStatus,
#[serde(default)]
pub pending_operation: Option<UserOperationSummary>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum UserOperationKind {
Create,
Attach,
Release,
Purge,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct UserOperationSummary {
pub operation: UserOperationKind,
pub phase: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CredentialStatus {
LocalPresent,
External,
Missing,
None,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct TuCredentialPreview {
pub user_id: i64,
pub username: String,
pub assigned_iota_id: Option<i64>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct UserReconcileResult {
pub user_id: i64,
pub local_state: LocalUserState,
pub omega_iota_id: Option<i64>,
pub action: ReconcileAction,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReconcileAction {
None,
ReleasedLocally,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct UserDiagnostics {
pub user_id: i64,
pub username: String,
pub local_state: LocalUserState,
pub data_present: bool,
pub credential_status: CredentialStatus,
pub trusted_app_count: usize,
pub pending_operation: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
@ -359,6 +459,11 @@ impl std::fmt::Display for IpcErrorCode {
#[cfg(test)]
mod error_tests {
use super::{
CredentialStatus, LocalUserState, ResponsePayload, SecretString, UserOperationKind,
UserOperationSummary, UserSummary,
};
use super::IpcErrorCode;
#[test]
@ -378,6 +483,50 @@ mod error_tests {
.contains("required role")
);
}
#[test]
fn credential_export_debug_output_is_redacted() {
let payload = ResponsePayload::UserCredentialExport {
user_id: 42,
username: "alice".into(),
credential: SecretString("private-tu-contents".into()),
};
let output = format!("{payload:?}");
assert!(!output.contains("private-tu-contents"));
assert!(output.contains("<redacted>"));
}
#[test]
fn user_summary_serializes_pending_operation_without_lifecycle_secrets() {
let summary = UserSummary {
user_id: 42,
username: "alice".into(),
state: LocalUserState::Managed,
data_present: true,
credential_status: CredentialStatus::LocalPresent,
pending_operation: Some(UserOperationSummary {
operation: UserOperationKind::Purge,
phase: "database_purged".into(),
}),
};
let encoded = serde_json::to_string(&summary).unwrap();
assert!(encoded.contains("purge"));
assert!(encoded.contains("database_purged"));
assert!(!encoded.contains("private_key"));
assert!(!encoded.contains("reset_token"));
}
#[test]
fn user_summary_accepts_older_payload_without_pending_operation() {
let summary: UserSummary = serde_json::from_str(
r#"{"user_id":42,"username":"alice","state":"managed","data_present":true,"credential_status":"local_present"}"#,
)
.unwrap();
assert!(summary.pending_operation.is_none());
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]

View file

@ -5,9 +5,16 @@ pub const COMMANDS: &[&str] = &[
"tasks",
"users list",
"users show ",
"users add ",
"users remove ",
"users import ",
"users add create ",
"users hosting release ",
"users data purge ",
"users account delete ",
"users repair reconcile ",
"users repair diagnostics ",
"users repair force-detach ",
"users forget ",
"users apps revoke ",
"users apps revoke-all ",
"omikron status",
"reconnect",
"identity rotate",
@ -59,16 +66,47 @@ pub fn parse(line: &str) -> Option<LocalRequest> {
let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::GetUser { user_id })
}
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
["user" | "users", "add", "create", username] => Some(LocalRequest::CreateUser {
username: username.to_string(),
}),
["user" | "users", "remove", id_str] => {
["user" | "users", "hosting", "release", id_str] => {
let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::RemoveUser { user_id })
Some(LocalRequest::ReleaseUser { user_id })
}
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
username: username.to_string(),
["user" | "users", "data", "purge", id_str] => Some(LocalRequest::PurgeUserData {
user_id: id_str.parse::<i64>().ok()?,
}),
["user" | "users", "account", "delete", id_str] => Some(LocalRequest::CompleteDeleteUser {
user_id: id_str.parse::<i64>().ok()?,
credential: None,
}),
["user" | "users", "repair", "reconcile", id_str] => Some(LocalRequest::ReconcileUser {
user_id: id_str.parse::<i64>().ok()?,
}),
["user" | "users", "repair", "diagnostics", id_str] => {
Some(LocalRequest::GetUserDiagnostics {
user_id: id_str.parse::<i64>().ok()?,
})
}
["user" | "users", "repair", "force-detach", id_str] => {
Some(LocalRequest::ForceDetachUser {
user_id: id_str.parse::<i64>().ok()?,
})
}
["user" | "users", "forget", id_str] => Some(LocalRequest::ForgetReleasedUser {
user_id: id_str.parse::<i64>().ok()?,
}),
["user" | "users", "apps", "revoke", id_str, app_id] => {
Some(LocalRequest::RevokeTrustedApp {
user_id: id_str.parse::<i64>().ok()?,
app_id: app_id.to_string(),
})
}
["user" | "users", "apps", "revoke-all", id_str] => {
Some(LocalRequest::RevokeAllTrustedApps {
user_id: id_str.parse::<i64>().ok()?,
})
}
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
@ -116,7 +154,7 @@ mod tests {
#[test]
fn parses_user_add() {
let req = parse("user add alice").unwrap();
let req = parse("user add create alice").unwrap();
match req {
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected CreateUser"),
@ -127,12 +165,20 @@ mod tests {
fn accepts_the_headless_cli_user_vocabulary() {
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
assert!(matches!(
parse("users add alice"),
parse("users add create alice"),
Some(LocalRequest::CreateUser { .. })
));
assert!(matches!(
parse("users remove 42"),
Some(LocalRequest::RemoveUser { user_id: 42 })
parse("users hosting release 42"),
Some(LocalRequest::ReleaseUser { user_id: 42 })
));
assert!(matches!(
parse("users apps revoke 42 desktop"),
Some(LocalRequest::RevokeTrustedApp { user_id: 42, .. })
));
assert!(matches!(
parse("users apps revoke-all 42"),
Some(LocalRequest::RevokeAllTrustedApps { user_id: 42 })
));
assert!(matches!(
parse("identity rotate"),
@ -141,17 +187,18 @@ mod tests {
}
#[test]
fn parses_user_remove_by_id() {
let req = parse("user remove 42").unwrap();
fn parses_user_release_by_id() {
let req = parse("user hosting release 42").unwrap();
match req {
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected RemoveUser"),
LocalRequest::ReleaseUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected ReleaseUser"),
}
}
#[test]
fn user_remove_requires_numeric_id() {
assert!(parse("user remove alice").is_none());
fn generic_remove_is_not_routed() {
assert!(parse("user remove 42").is_none());
assert!(parse("users remove alice").is_none());
}
#[test]
@ -295,12 +342,8 @@ mod tests {
}
#[test]
fn parses_users_import() {
let req = parse("users import alice").unwrap();
match req {
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected ImportUser"),
}
fn deprecated_import_is_not_routed() {
assert!(parse("users import alice").is_none());
}
#[test]
@ -317,7 +360,15 @@ mod tests {
#[test]
fn completion_is_prefix_based_and_deterministic() {
assert_eq!(completions("identity r"), vec!["identity rotate"]);
assert_eq!(completions("/users a"), vec!["users add "]);
assert_eq!(
completions("/users a"),
vec![
"users add create ",
"users account delete ",
"users apps revoke ",
"users apps revoke-all ",
]
);
assert!(completions("definitely-unknown").is_empty());
}