2026-03-21 14:38:18 +00:00
|
|
|
//! User provisioning -- onboarding and offboarding workflows.
|
|
|
|
|
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
|
|
|
|
use crate::error::{Result, ResultExt, SunbeamError};
|
|
|
|
|
use crate::output::{ok, step, warn};
|
|
|
|
|
|
|
|
|
|
use super::{
|
|
|
|
|
api, find_identity, generate_recovery, identity_id, identity_put_body, kratos_api,
|
|
|
|
|
next_employee_id, short_id, PortForward, SMTP_LOCAL_PORT,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Onboard
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-07 19:26:11 +01:00
|
|
|
/// Send a welcome email via cluster Stalwart (port-forward to svc/stalwart in stalwart ns).
|
2026-03-21 14:38:18 +00:00
|
|
|
async fn send_welcome_email(
|
|
|
|
|
domain: &str,
|
|
|
|
|
email: &str,
|
|
|
|
|
name: &str,
|
|
|
|
|
recovery_link: &str,
|
|
|
|
|
recovery_code: &str,
|
|
|
|
|
job_title: &str,
|
|
|
|
|
department: &str,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let greeting = if name.is_empty() {
|
|
|
|
|
"Hi".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("Hi {name}")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let joining_line = if !job_title.is_empty() && !department.is_empty() {
|
|
|
|
|
format!(
|
|
|
|
|
" You're joining as {job_title} in the {department} department."
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let body_text = format!(
|
|
|
|
|
"{greeting},
|
|
|
|
|
|
|
|
|
|
Welcome to Sunbeam Studios!{joining_line} Your account has been created.
|
|
|
|
|
|
|
|
|
|
To set your password, open this link and enter the recovery code below:
|
|
|
|
|
|
|
|
|
|
Link: {recovery_link}
|
|
|
|
|
Code: {recovery_code}
|
|
|
|
|
|
|
|
|
|
This link expires in 24 hours.
|
|
|
|
|
|
|
|
|
|
Once signed in you will be prompted to set up 2FA (mandatory).
|
|
|
|
|
|
|
|
|
|
After that, head to https://auth.{domain}/settings to set up your
|
|
|
|
|
profile -- add your name, profile picture, and any other details.
|
|
|
|
|
|
|
|
|
|
Your services:
|
|
|
|
|
Mail: https://mail.{domain}
|
|
|
|
|
Source Code: https://src.{domain}
|
|
|
|
|
|
|
|
|
|
Messages (Matrix):
|
|
|
|
|
Download Element from https://element.io/download
|
|
|
|
|
Open Element and sign in with a custom homeserver:
|
|
|
|
|
Homeserver: https://messages.{domain}
|
|
|
|
|
Use \"Sign in with Sunbeam Studios\" (SSO) to log in.
|
|
|
|
|
|
|
|
|
|
-- With Love & Warmth, Sunbeam Studios
|
|
|
|
|
"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
use lettre::message::Mailbox;
|
|
|
|
|
use lettre::{Message, SmtpTransport, Transport};
|
|
|
|
|
|
|
|
|
|
let from: Mailbox = format!("Sunbeam Studios <noreply@{domain}>")
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("Invalid from address: {e}")))?;
|
|
|
|
|
let to: Mailbox = email
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("Invalid recipient address: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let message = Message::builder()
|
|
|
|
|
.from(from)
|
|
|
|
|
.to(to)
|
|
|
|
|
.subject("Welcome to Sunbeam Studios -- Set Your Password")
|
|
|
|
|
.body(body_text)
|
|
|
|
|
.ctx("Failed to build email message")?;
|
|
|
|
|
|
2026-04-07 19:26:11 +01:00
|
|
|
let _pf = PortForward::new("stalwart", "stalwart", SMTP_LOCAL_PORT, 25).await?;
|
2026-03-21 14:38:18 +00:00
|
|
|
|
|
|
|
|
let mailer = SmtpTransport::builder_dangerous("localhost")
|
|
|
|
|
.port(SMTP_LOCAL_PORT)
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
|
|
|
mailer
|
|
|
|
|
.send(&message)
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("Failed to send welcome email via SMTP: {e}")))
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("Email send task panicked: {e}")))??;
|
|
|
|
|
|
|
|
|
|
ok(&format!("Welcome email sent to {email}"));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
|
|
|
pub async fn cmd_user_onboard(
|
|
|
|
|
email: &str,
|
|
|
|
|
name: &str,
|
|
|
|
|
schema_id: &str,
|
|
|
|
|
send_email: bool,
|
|
|
|
|
notify: &str,
|
|
|
|
|
job_title: &str,
|
|
|
|
|
department: &str,
|
|
|
|
|
office_location: &str,
|
|
|
|
|
hire_date: &str,
|
|
|
|
|
manager: &str,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
step(&format!("Onboarding: {email}"));
|
|
|
|
|
|
|
|
|
|
let pf = PortForward::kratos().await?;
|
|
|
|
|
|
2026-04-07 19:26:11 +01:00
|
|
|
let (iid, recovery_link, recovery_code, _is_new) = {
|
2026-03-21 14:38:18 +00:00
|
|
|
let existing = find_identity(&pf.base_url, email, false).await?;
|
|
|
|
|
|
|
|
|
|
if let Some(existing) = existing {
|
|
|
|
|
let iid = identity_id(&existing)?;
|
|
|
|
|
warn(&format!("Identity already exists: {}...", short_id(&iid)));
|
|
|
|
|
step("Generating fresh recovery link...");
|
|
|
|
|
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
|
|
|
|
|
(iid, link, code, false)
|
|
|
|
|
} else {
|
|
|
|
|
let mut traits = serde_json::json!({ "email": email });
|
|
|
|
|
if !name.is_empty() {
|
|
|
|
|
let parts: Vec<&str> = name.splitn(2, ' ').collect();
|
|
|
|
|
traits["given_name"] = Value::String(parts[0].to_string());
|
|
|
|
|
traits["family_name"] =
|
|
|
|
|
Value::String(if parts.len() > 1 { parts[1] } else { "" }.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut employee_id = String::new();
|
|
|
|
|
if schema_id == "employee" {
|
|
|
|
|
employee_id = next_employee_id(&pf.base_url).await?;
|
|
|
|
|
traits["employee_id"] = Value::String(employee_id.clone());
|
|
|
|
|
if !job_title.is_empty() {
|
|
|
|
|
traits["job_title"] = Value::String(job_title.to_string());
|
|
|
|
|
}
|
|
|
|
|
if !department.is_empty() {
|
|
|
|
|
traits["department"] = Value::String(department.to_string());
|
|
|
|
|
}
|
|
|
|
|
if !office_location.is_empty() {
|
|
|
|
|
traits["office_location"] = Value::String(office_location.to_string());
|
|
|
|
|
}
|
|
|
|
|
if !hire_date.is_empty() {
|
|
|
|
|
traits["hire_date"] = Value::String(hire_date.to_string());
|
|
|
|
|
}
|
|
|
|
|
if !manager.is_empty() {
|
|
|
|
|
traits["manager"] = Value::String(manager.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"schema_id": schema_id,
|
|
|
|
|
"traits": traits,
|
|
|
|
|
"state": "active",
|
|
|
|
|
"verifiable_addresses": [{
|
|
|
|
|
"value": email,
|
|
|
|
|
"verified": true,
|
|
|
|
|
"via": "email",
|
|
|
|
|
}],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])
|
|
|
|
|
.await?
|
|
|
|
|
.ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
|
|
|
|
|
|
|
|
|
|
let iid = identity_id(&identity)?;
|
|
|
|
|
ok(&format!("Created identity: {iid}"));
|
|
|
|
|
if !employee_id.is_empty() {
|
|
|
|
|
ok(&format!("Employee #{employee_id}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Kratos ignores verifiable_addresses on POST -- PATCH to mark verified
|
|
|
|
|
let patch_body = serde_json::json!([
|
|
|
|
|
{"op": "replace", "path": "/verifiable_addresses/0/verified", "value": true},
|
|
|
|
|
{"op": "replace", "path": "/verifiable_addresses/0/status", "value": "completed"},
|
|
|
|
|
]);
|
|
|
|
|
kratos_api(
|
|
|
|
|
&pf.base_url,
|
|
|
|
|
&format!("/identities/{iid}"),
|
|
|
|
|
"PATCH",
|
|
|
|
|
Some(&patch_body),
|
|
|
|
|
&[],
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
|
|
|
|
|
(iid, link, code, true)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
drop(pf);
|
|
|
|
|
|
|
|
|
|
if send_email {
|
|
|
|
|
let domain = crate::kube::get_domain().await?;
|
|
|
|
|
let recipient = if notify.is_empty() { email } else { notify };
|
|
|
|
|
send_welcome_email(
|
|
|
|
|
&domain, recipient, name, &recovery_link, &recovery_code,
|
|
|
|
|
job_title, department,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ok(&format!("Identity ID: {iid}"));
|
|
|
|
|
ok("Recovery link (valid 24h):");
|
|
|
|
|
println!("{recovery_link}");
|
|
|
|
|
ok("Recovery code:");
|
|
|
|
|
println!("{recovery_code}");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Offboard
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
pub async fn cmd_user_offboard(target: &str) -> Result<()> {
|
|
|
|
|
step(&format!("Offboarding: {target}"));
|
|
|
|
|
|
|
|
|
|
eprint!("Offboard '{target}'? This will disable the account and revoke all sessions. [y/N] ");
|
|
|
|
|
std::io::stderr().flush()?;
|
|
|
|
|
let mut answer = String::new();
|
|
|
|
|
std::io::stdin().read_line(&mut answer)?;
|
|
|
|
|
if answer.trim().to_lowercase() != "y" {
|
|
|
|
|
ok("Cancelled.");
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let pf = PortForward::kratos().await?;
|
|
|
|
|
let identity = find_identity(&pf.base_url, target, true)
|
|
|
|
|
.await?
|
|
|
|
|
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
|
|
|
|
let iid = identity_id(&identity)?;
|
|
|
|
|
|
|
|
|
|
step("Disabling identity...");
|
|
|
|
|
let put_body = identity_put_body(&identity, Some("inactive"), None);
|
|
|
|
|
kratos_api(
|
|
|
|
|
&pf.base_url,
|
|
|
|
|
&format!("/identities/{iid}"),
|
|
|
|
|
"PUT",
|
|
|
|
|
Some(&put_body),
|
|
|
|
|
&[],
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
ok(&format!("Identity {}... disabled.", short_id(&iid)));
|
|
|
|
|
|
|
|
|
|
step("Revoking Kratos sessions...");
|
|
|
|
|
kratos_api(
|
|
|
|
|
&pf.base_url,
|
|
|
|
|
&format!("/identities/{iid}/sessions"),
|
|
|
|
|
"DELETE",
|
|
|
|
|
None,
|
|
|
|
|
&[404],
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
ok("Kratos sessions revoked.");
|
|
|
|
|
|
|
|
|
|
step("Revoking Hydra consent sessions...");
|
|
|
|
|
{
|
|
|
|
|
let hydra_pf = PortForward::new("ory", "hydra-admin", 14445, 4445).await?;
|
|
|
|
|
api(
|
|
|
|
|
&hydra_pf.base_url,
|
|
|
|
|
&format!("/oauth2/auth/sessions/consent?subject={iid}&all=true"),
|
|
|
|
|
"DELETE",
|
|
|
|
|
None,
|
|
|
|
|
"/admin",
|
|
|
|
|
&[404],
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
ok("Hydra consent sessions revoked.");
|
|
|
|
|
|
|
|
|
|
drop(pf);
|
|
|
|
|
|
|
|
|
|
ok(&format!("Offboarding complete for {}...", short_id(&iid)));
|
|
|
|
|
warn("Existing access tokens expire within ~1h (Hydra TTL).");
|
2026-04-07 19:26:11 +01:00
|
|
|
warn("App sessions expire within SESSION_COOKIE_AGE (~1h).");
|
2026-03-21 14:38:18 +00:00
|
|
|
Ok(())
|
|
|
|
|
}
|