From 3480240feb5c26022aa86eb2fdbc9461a1822cac Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 15:21:17 -0500 Subject: [PATCH 1/6] feat(invite): email transport, invite store, and PASETO invite token Foundation for issue #71 user invite & onboard: - email.rs: [schema_forge.email] config + EmailSender trait with lettre SMTP impl (aws-lc-rs rustls, FIPS-clean) and InMemoryEmailSender fake. SMTP password is env-only, never committed TOML. - invite_store.rs: InviteStore over an internal ForgeInvitation entity table provisioned at boot but never registered in the SchemaRegistry, so it stays unreachable from the public schema/entity API. - invite.rs: mint_invite_token/verify_invite_token. Invites are PASETOs minted with the login generator but carry purpose="invite"; verify refuses any token lacking it, hard-separating invite links from API sessions. Role/tenant are read from the signed claims, authoritative over the stored row. Endpoints and wiring follow in subsequent commits. --- Cargo.lock | 63 +++ crates/schema-forge-acton/Cargo.toml | 1 + crates/schema-forge-acton/src/config.rs | 8 + crates/schema-forge-acton/src/email.rs | 345 +++++++++++++++ crates/schema-forge-acton/src/invite.rs | 263 +++++++++++ crates/schema-forge-acton/src/lib.rs | 2 + crates/schema-forge-backend/Cargo.toml | 1 + .../schema-forge-backend/src/invite_store.rs | 417 ++++++++++++++++++ crates/schema-forge-backend/src/lib.rs | 5 + 9 files changed, 1105 insertions(+) create mode 100644 crates/schema-forge-acton/src/email.rs create mode 100644 crates/schema-forge-acton/src/invite.rs create mode 100644 crates/schema-forge-backend/src/invite_store.rs diff --git a/Cargo.lock b/Cargo.lock index 85eced1..9c3b060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2773,6 +2773,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "ena" version = "0.14.3" @@ -3701,6 +3717,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.35.0" @@ -4414,6 +4441,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls 0.23.40", + "socket2 0.6.2", + "tokio", + "tokio-rustls 0.26.4", + "url", + "webpki-roots 1.0.6", +] + [[package]] name = "lexicmp" version = "0.1.0" @@ -6180,6 +6235,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -7166,6 +7227,7 @@ dependencies = [ "http 1.4.0", "http-body-util", "humantime", + "lettre", "prost 0.14.3", "prost-build", "prost-reflect", @@ -7201,6 +7263,7 @@ version = "0.12.0" dependencies = [ "acton-service", "argon2", + "async-trait", "chrono", "proptest", "schema-forge-core", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index d232fae..5d60463 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -43,6 +43,7 @@ toml = "1" aws-lc-rs = { version = "1", features = ["fips"], optional = true } rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "logging"] } schema-forge-signing = { version = "0.1.0", path = "../schema-forge-signing" } +lettre = { version = "0.11.22", default-features = false, features = ["tokio1-rustls", "aws-lc-rs", "webpki-roots", "smtp-transport", "builder", "pool", "hostname"] } [features] diff --git a/crates/schema-forge-acton/src/config.rs b/crates/schema-forge-acton/src/config.rs index 0195241..2a122e6 100644 --- a/crates/schema-forge-acton/src/config.rs +++ b/crates/schema-forge-acton/src/config.rs @@ -39,6 +39,12 @@ pub struct SchemaForgeSettings { #[serde(default)] pub storage: crate::storage::StorageConfig, + /// Outbound email (SMTP) settings, used by operational flows that must + /// reach a human out-of-band — currently user invitations. Disabled by + /// default; see [`crate::email::EmailConfig`]. + #[serde(default)] + pub email: crate::email::EmailConfig, + /// Authorization configuration. Currently exposes operator-defined /// PASETO custom-claim → Cedar `Forge::Principal` attribute mappings; /// see [`crate::authz::principal_claims`]. @@ -131,6 +137,7 @@ impl Default for SchemaForgeSettings { webhooks: crate::webhook::WebhookConfig::default(), hooks: crate::hooks::HooksConfig::default(), storage: crate::storage::StorageConfig::default(), + email: crate::email::EmailConfig::default(), authz: AuthzConfig::default(), signing: SigningConfig::default(), client: ClientConfig::default(), @@ -158,6 +165,7 @@ mod tests { webhooks: crate::webhook::WebhookConfig::default(), hooks: crate::hooks::HooksConfig::default(), storage: crate::storage::StorageConfig::default(), + email: crate::email::EmailConfig::default(), authz: AuthzConfig::default(), signing: SigningConfig::default(), client: ClientConfig::default(), diff --git a/crates/schema-forge-acton/src/email.rs b/crates/schema-forge-acton/src/email.rs new file mode 100644 index 0000000..72e6b5e --- /dev/null +++ b/crates/schema-forge-acton/src/email.rs @@ -0,0 +1,345 @@ +//! Outbound email transport for SchemaForge. +//! +//! SchemaForge has no email needs of its own beyond operational flows that +//! must reach a human out-of-band — today, user invitations (issue #71). The +//! transport is modelled as a trait object ([`EmailSender`]) so handlers stay +//! decoupled from the wire protocol: production wires [`SmtpEmailSender`] +//! (lettre over implicit-TLS SMTPS by default), while tests substitute +//! [`InMemoryEmailSender`] and assert on what would have been sent. +//! +//! TLS uses the workspace's `aws-lc-rs` rustls provider (lettre's `aws-lc-rs` +//! feature), keeping the crypto backend consistent with the rest of the +//! service and aligned with the FIPS build. +//! +//! Credentials are **never** read from a committed file: the +//! [`EmailConfig::password`] field is an `Option` populated by +//! acton-service's config layering from the environment, so the secret enters +//! the process at runtime and stays out of `config.toml` and git. + +use async_trait::async_trait; +use lettre::message::header::ContentType; +use lettre::message::Mailbox; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; + +/// `[schema_forge.email]` section of `config.toml`. +/// +/// Disabled by default: a deployment that never sends mail carries no SMTP +/// configuration and the invite endpoints that require delivery fail closed +/// with [`EmailError::NotConfigured`] rather than silently dropping messages. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + /// Master switch. When `false`, no transport is constructed and any flow + /// that needs to send mail is refused with a clear error. + #[serde(default)] + pub enabled: bool, + + /// SMTP relay hostname, e.g. `"mail.govcraft.ai"`. + #[serde(default)] + pub host: Option, + + /// SMTP port. Defaults to 465 (implicit TLS / SMTPS). + #[serde(default = "default_smtp_port")] + pub port: u16, + + /// Transport security mode. Defaults to [`EmailTls::Implicit`] (SMTPS). + #[serde(default)] + pub tls: EmailTls, + + /// `From` mailbox, e.g. `"SchemaForge "`. + #[serde(default)] + pub from: Option, + + /// SMTP AUTH username. Omit for unauthenticated relays. + #[serde(default)] + pub username: Option, + + /// SMTP AUTH password. **Never** place this in a committed `config.toml`; + /// supply it through the environment so acton-service's config layering + /// fills it at runtime. Kept `Option` precisely so the on-disk file can + /// omit it. + #[serde(default)] + pub password: Option, + + /// Public base URL of the deployed site, e.g. `"https://app.agency.gov"`. + /// Used to build absolute links (such as invite-accept URLs) inside + /// emails, where a request-relative path would be useless to the + /// recipient. + #[serde(default)] + pub public_base_url: Option, +} + +impl Default for EmailConfig { + fn default() -> Self { + Self { + enabled: false, + host: None, + port: default_smtp_port(), + tls: EmailTls::default(), + from: None, + username: None, + password: None, + public_base_url: None, + } + } +} + +fn default_smtp_port() -> u16 { + 465 +} + +/// SMTP transport-security mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum EmailTls { + /// Implicit TLS (SMTPS) — TLS from connect, conventionally port 465. + #[default] + Implicit, + /// Opportunistic TLS via the STARTTLS command, conventionally port 587. + StartTls, +} + +/// A plain-text email ready to hand to a transport. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmailMessage { + /// Recipient mailbox (`"Name "` or bare `"addr"`). + pub to: String, + /// Subject line. + pub subject: String, + /// Plain-text body. + pub body_text: String, +} + +/// Errors raised while configuring or using an [`EmailSender`]. +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + /// Email delivery was requested but no usable transport is configured. + #[error("email is not configured; set [schema_forge.email] enabled = true with host and from")] + NotConfigured, + /// The configured values could not produce a transport. + #[error("invalid email configuration: {0}")] + InvalidConfig(String), + /// A `to`/`from` address failed to parse. + #[error("invalid email address: {0}")] + InvalidAddress(String), + /// The transport failed to build or deliver the message. + #[error("smtp transport error: {0}")] + Transport(String), +} + +/// Sends outbound email. Object-safe (`async_trait`) so it can be held as an +/// `Arc` extension and swapped for a fake in tests. +#[async_trait] +pub trait EmailSender: Send + Sync { + /// Deliver `message`, resolving once the relay has accepted it. + async fn send(&self, message: EmailMessage) -> Result<(), EmailError>; + + /// Public base URL for building absolute links inside message bodies, + /// if one is configured. + fn public_base_url(&self) -> Option<&str>; +} + +/// Production [`EmailSender`] backed by lettre's async SMTP transport. +pub struct SmtpEmailSender { + transport: AsyncSmtpTransport, + from: Mailbox, + public_base_url: Option, +} + +impl SmtpEmailSender { + /// Build a transport from `[schema_forge.email]`. + /// + /// Returns [`EmailError::NotConfigured`] when `enabled = false`, and + /// [`EmailError::InvalidConfig`] when a required field (`host`, `from`) + /// is missing or malformed — surfacing misconfiguration at startup rather + /// than on the first invite. + pub fn from_config(cfg: &EmailConfig) -> Result { + if !cfg.enabled { + return Err(EmailError::NotConfigured); + } + let host = cfg + .host + .as_deref() + .filter(|h| !h.is_empty()) + .ok_or_else(|| EmailError::InvalidConfig("host is required".to_string()))?; + let from_raw = cfg + .from + .as_deref() + .filter(|f| !f.is_empty()) + .ok_or_else(|| EmailError::InvalidConfig("from is required".to_string()))?; + let from: Mailbox = from_raw + .parse() + .map_err(|e| EmailError::InvalidAddress(format!("from '{from_raw}': {e}")))?; + + let builder = match cfg.tls { + EmailTls::Implicit => AsyncSmtpTransport::::relay(host), + EmailTls::StartTls => AsyncSmtpTransport::::starttls_relay(host), + } + .map_err(|e| EmailError::InvalidConfig(e.to_string()))? + .port(cfg.port); + + let builder = match (cfg.username.as_deref(), cfg.password.as_deref()) { + (Some(user), Some(pass)) if !user.is_empty() => { + builder.credentials(Credentials::new(user.to_string(), pass.to_string())) + } + _ => builder, + }; + + Ok(Self { + transport: builder.build(), + from, + public_base_url: cfg.public_base_url.clone(), + }) + } +} + +#[async_trait] +impl EmailSender for SmtpEmailSender { + async fn send(&self, message: EmailMessage) -> Result<(), EmailError> { + let to: Mailbox = message + .to + .parse() + .map_err(|e| EmailError::InvalidAddress(format!("to '{}': {e}", message.to)))?; + let email = Message::builder() + .from(self.from.clone()) + .to(to) + .subject(message.subject) + .header(ContentType::TEXT_PLAIN) + .body(message.body_text) + .map_err(|e| EmailError::Transport(e.to_string()))?; + self.transport + .send(email) + .await + .map_err(|e| EmailError::Transport(e.to_string()))?; + Ok(()) + } + + fn public_base_url(&self) -> Option<&str> { + self.public_base_url.as_deref() + } +} + +/// In-memory [`EmailSender`] that records messages instead of sending them. +/// +/// Used by tests to assert on delivered content (e.g. that an invite-accept +/// link was produced). It never fails, so it must not stand in for a real +/// transport in production — the wiring selects [`SmtpEmailSender`] there. +#[derive(Debug, Default)] +pub struct InMemoryEmailSender { + sent: Mutex>, + public_base_url: Option, +} + +impl InMemoryEmailSender { + /// Create a recorder with an optional base URL for link construction. + pub fn new(public_base_url: Option) -> Self { + Self { + sent: Mutex::new(Vec::new()), + public_base_url, + } + } + + /// Snapshot of every message handed to [`EmailSender::send`] so far. + pub fn sent(&self) -> Vec { + self.sent.lock().expect("email recorder mutex poisoned").clone() + } +} + +#[async_trait] +impl EmailSender for InMemoryEmailSender { + async fn send(&self, message: EmailMessage) -> Result<(), EmailError> { + self.sent + .lock() + .expect("email recorder mutex poisoned") + .push(message); + Ok(()) + } + + fn public_base_url(&self) -> Option<&str> { + self.public_base_url.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults_to_disabled_smtps() { + let cfg = EmailConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.port, 465); + assert_eq!(cfg.tls, EmailTls::Implicit); + assert!(cfg.host.is_none()); + } + + #[test] + fn config_deserialises_without_password() { + // Committed config omits the secret; the loader supplies it from env. + let toml = r#" + [schema_forge.email] + enabled = true + host = "mail.govcraft.ai" + port = 465 + tls = "implicit" + from = "SchemaForge " + username = "noreply@govcraft.ai" + public_base_url = "https://app.agency.gov" + "#; + #[derive(Deserialize)] + struct Wrapper { + schema_forge: Inner, + } + #[derive(Deserialize)] + struct Inner { + email: EmailConfig, + } + let w: Wrapper = toml::from_str(toml).unwrap(); + let email = w.schema_forge.email; + assert!(email.enabled); + assert_eq!(email.host.as_deref(), Some("mail.govcraft.ai")); + assert_eq!(email.tls, EmailTls::Implicit); + assert!(email.password.is_none()); + assert_eq!(email.public_base_url.as_deref(), Some("https://app.agency.gov")); + } + + #[test] + fn smtp_sender_refuses_when_disabled() { + let cfg = EmailConfig::default(); + assert!(matches!( + SmtpEmailSender::from_config(&cfg), + Err(EmailError::NotConfigured) + )); + } + + #[test] + fn smtp_sender_requires_host_and_from() { + let cfg = EmailConfig { + enabled: true, + ..EmailConfig::default() + }; + assert!(matches!( + SmtpEmailSender::from_config(&cfg), + Err(EmailError::InvalidConfig(_)) + )); + } + + #[tokio::test] + async fn in_memory_sender_records_messages() { + let sender = InMemoryEmailSender::new(Some("https://app.agency.gov".to_string())); + sender + .send(EmailMessage { + to: "user@example.gov".to_string(), + subject: "Welcome".to_string(), + body_text: "hello".to_string(), + }) + .await + .unwrap(); + let sent = sender.sent(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].to, "user@example.gov"); + assert_eq!(sender.public_base_url(), Some("https://app.agency.gov")); + } +} diff --git a/crates/schema-forge-acton/src/invite.rs b/crates/schema-forge-acton/src/invite.rs new file mode 100644 index 0000000..ec170f4 --- /dev/null +++ b/crates/schema-forge-acton/src/invite.rs @@ -0,0 +1,263 @@ +//! Invite-token minting and verification (issue #71). +//! +//! An invitation is carried as a PASETO minted with the **same** generator and +//! key the login endpoint uses, so it inherits the deployment's existing key +//! management and rotation story. What distinguishes it from a session bearer +//! is a `purpose = "invite"` custom claim: [`verify_invite_token`] refuses any +//! token lacking it, and the bearer-auth middleware would likewise reject an +//! invite token presented as a credential because no route grants access on a +//! `purpose=invite` claim. That hard separation is the whole point — an invite +//! link can never be replayed as an API session, nor a leaked session minted +//! into an invite. +//! +//! The emailed accept link carries only the opaque, high-entropy `invite_id`. +//! The full token is persisted in the invite store and *reconstructed* on +//! accept, where it is re-verified here so the invitee's role and tenant are +//! read from the **signed** claims rather than from mutable database columns — +//! defense-in-depth against tampering with the stored row. + +use std::time::Duration; + +use acton_service::auth::tokens::paseto_generator::PasetoGenerator; +use acton_service::auth::tokens::{ClaimsBuilder, TokenGenerator}; +use acton_service::middleware::token::TokenValidator; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Custom-claim value that marks a token as an invitation rather than a +/// session credential. +pub const INVITE_PURPOSE: &str = "invite"; + +const CLAIM_PURPOSE: &str = "purpose"; +const CLAIM_INVITE_ID: &str = "invite_id"; +const CLAIM_EMAIL: &str = "invite_email"; +const CLAIM_TENANT_TYPE: &str = "invite_tenant_type"; +const CLAIM_TENANT_ID: &str = "invite_tenant_id"; +const CLAIM_ROLE: &str = "invite_role"; + +/// Parameters baked into a freshly minted invite token. +#[derive(Debug, Clone)] +pub struct InviteTokenParams { + /// Invitee email — becomes the future `User.email` and the token subject. + pub email: String, + /// Tenant root type the invitee is being added to (e.g. `"Organization"`). + pub tenant_type: Option, + /// Tenant root entity id. + pub tenant_id: Option, + /// Role granted within that tenant. + pub role: Option, +} + +/// Result of minting: the opaque id to email, the full token to persist, and +/// the absolute expiry to store. +#[derive(Debug, Clone)] +pub struct MintedInvite { + /// High-entropy opaque reference emailed to the invitee. + pub invite_id: String, + /// The full PASETO; persisted in the invite store, reconstructed on accept. + pub token: String, + /// Absolute expiry (mirrors the token's `exp`). + pub expires_at: DateTime, +} + +/// The trusted contents of a verified invite token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedInvite { + pub invite_id: String, + pub email: String, + pub tenant_type: Option, + pub tenant_id: Option, + pub role: Option, +} + +/// Errors from minting or verifying an invite token. +#[derive(Debug, thiserror::Error)] +pub enum InviteTokenError { + /// The token could not be minted (claims build or generator failure). + #[error("failed to mint invite token: {0}")] + Mint(String), + /// The token failed cryptographic validation or has expired. + #[error("invite token is invalid or expired: {0}")] + Invalid(String), + /// The token verified but is not an invitation (missing/incorrect purpose). + #[error("token is not an invitation")] + WrongPurpose, + /// A required claim was absent from an otherwise valid invite token. + #[error("invite token is missing the '{0}' claim")] + MissingClaim(&'static str), +} + +/// Mint an invitation token. `ttl` sets both the PASETO `exp` and the returned +/// [`MintedInvite::expires_at`]. The `invite_id` is a fresh v4 UUID (122 bits +/// of entropy), unguessable so possession of the emailed link is the only way +/// to present a valid reference. +pub fn mint_invite_token( + generator: &PasetoGenerator, + params: &InviteTokenParams, + ttl: Duration, +) -> Result { + let invite_id = Uuid::new_v4().to_string(); + + let mut builder = ClaimsBuilder::new() + .subject(format!("invite:{}", params.email)) + .custom_claim(CLAIM_PURPOSE, INVITE_PURPOSE.to_string()) + .custom_claim(CLAIM_INVITE_ID, invite_id.clone()) + .custom_claim(CLAIM_EMAIL, params.email.clone()); + if let Some(tt) = params.tenant_type.as_deref().filter(|s| !s.is_empty()) { + builder = builder.custom_claim(CLAIM_TENANT_TYPE, tt.to_string()); + } + if let Some(ti) = params.tenant_id.as_deref().filter(|s| !s.is_empty()) { + builder = builder.custom_claim(CLAIM_TENANT_ID, ti.to_string()); + } + if let Some(role) = params.role.as_deref().filter(|s| !s.is_empty()) { + builder = builder.custom_claim(CLAIM_ROLE, role.to_string()); + } + + let claims = builder + .build() + .map_err(|e| InviteTokenError::Mint(e.to_string()))?; + let token = generator + .generate_token_with_expiry(&claims, ttl) + .map_err(|e| InviteTokenError::Mint(e.to_string()))?; + + let expires_at = Utc::now() + chrono::Duration::seconds(ttl.as_secs() as i64); + Ok(MintedInvite { + invite_id, + token, + expires_at, + }) +} + +/// Verify an invite token: validates the signature and expiry through the +/// supplied [`TokenValidator`], then enforces `purpose = "invite"` and extracts +/// the trusted invite parameters from the signed claims. +pub fn verify_invite_token( + validator: &V, + token: &str, +) -> Result { + let claims = validator + .validate_token(token) + .map_err(|e| InviteTokenError::Invalid(e.to_string()))?; + + if claim_str(&claims, CLAIM_PURPOSE).as_deref() != Some(INVITE_PURPOSE) { + return Err(InviteTokenError::WrongPurpose); + } + + let invite_id = + claim_str(&claims, CLAIM_INVITE_ID).ok_or(InviteTokenError::MissingClaim("invite_id"))?; + let email = + claim_str(&claims, CLAIM_EMAIL).ok_or(InviteTokenError::MissingClaim("invite_email"))?; + + Ok(VerifiedInvite { + invite_id, + email, + tenant_type: claim_str(&claims, CLAIM_TENANT_TYPE), + tenant_id: claim_str(&claims, CLAIM_TENANT_ID), + role: claim_str(&claims, CLAIM_ROLE), + }) +} + +fn claim_str(claims: &acton_service::middleware::token::Claims, key: &str) -> Option { + claims + .custom_claim(key) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use acton_service::auth::config::TokenGenerationConfig; + use acton_service::config::PasetoConfig; + use acton_service::middleware::paseto::PasetoAuth; + use std::io::Write; + use std::path::PathBuf; + + /// Write a fresh 32-byte symmetric key to a temp file and return a + /// matched (generator, validator) pair plus the key path for cleanup. + fn key_pair() -> (PasetoGenerator, PasetoAuth, PathBuf) { + let mut key = [0u8; 32]; + // Deterministic-but-arbitrary fill; the value is irrelevant, only that + // the generator and validator share it. (Test-only.) + for (i, b) in key.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(3); + } + let path = std::env::temp_dir().join(format!("sf-invite-test-{}.key", Uuid::new_v4())); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(&key).unwrap(); + f.flush().unwrap(); + + let gen_cfg = TokenGenerationConfig { + access_token_lifetime_secs: 3600, + issuer: Some("schemaforge-test".to_string()), + audience: None, + include_jti: true, + }; + let generator = PasetoGenerator::with_symmetric_key(key, gen_cfg); + let validator = PasetoAuth::new(&PasetoConfig { + version: "v4".to_string(), + purpose: "local".to_string(), + key_path: path.clone(), + issuer: None, + audience: None, + public_paths: vec![], + }) + .unwrap(); + (generator, validator, path) + } + + fn params() -> InviteTokenParams { + InviteTokenParams { + email: "invitee@example.gov".to_string(), + tenant_type: Some("Organization".to_string()), + tenant_id: Some("org_abc".to_string()), + role: Some("member".to_string()), + } + } + + #[test] + fn mint_then_verify_roundtrips() { + let (generator, validator, path) = key_pair(); + let minted = mint_invite_token(&generator, ¶ms(), Duration::from_secs(3600)).unwrap(); + assert!(!minted.invite_id.is_empty()); + assert!(minted.expires_at > Utc::now()); + + let verified = verify_invite_token(&validator, &minted.token).unwrap(); + assert_eq!(verified.invite_id, minted.invite_id); + assert_eq!(verified.email, "invitee@example.gov"); + assert_eq!(verified.tenant_type.as_deref(), Some("Organization")); + assert_eq!(verified.tenant_id.as_deref(), Some("org_abc")); + assert_eq!(verified.role.as_deref(), Some("member")); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn non_invite_token_is_rejected() { + let (generator, validator, path) = key_pair(); + // A login-shaped token with no purpose=invite claim. + let claims = ClaimsBuilder::new() + .subject("user:someone") + .role("admin") + .build() + .unwrap(); + let token = generator + .generate_token_with_expiry(&claims, Duration::from_secs(3600)) + .unwrap(); + assert!(matches!( + verify_invite_token(&validator, &token), + Err(InviteTokenError::WrongPurpose) + )); + let _ = std::fs::remove_file(path); + } + + #[test] + fn garbage_token_is_invalid() { + let (_generator, validator, path) = key_pair(); + assert!(matches!( + verify_invite_token(&validator, "v4.local.not-a-real-token"), + Err(InviteTokenError::Invalid(_)) + )); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/schema-forge-acton/src/lib.rs b/crates/schema-forge-acton/src/lib.rs index c968c96..4959601 100644 --- a/crates/schema-forge-acton/src/lib.rs +++ b/crates/schema-forge-acton/src/lib.rs @@ -5,8 +5,10 @@ pub mod cedar; pub mod config; pub mod conversions; pub mod crypto; +pub mod email; pub mod error; pub mod extension; +pub mod invite; #[cfg(feature = "graphql")] pub mod graphql; pub mod hooks; diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index 110d395..a288ba8 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] acton-service = { version = "0.27.0", default-features = false, features = ["crypto-aws-lc-rs"] } argon2 = { version = "0.5", features = ["std"] } +async-trait = "0.1.89" chrono = { version = "0.4.44", features = ["serde"] } schema-forge-core = { path = "../schema-forge-core" } serde = { version = "1", features = ["derive"] } diff --git a/crates/schema-forge-backend/src/invite_store.rs b/crates/schema-forge-backend/src/invite_store.rs new file mode 100644 index 0000000..5f5e62e --- /dev/null +++ b/crates/schema-forge-backend/src/invite_store.rs @@ -0,0 +1,417 @@ +//! Pending-invitation store for the user invite/onboard flow (issue #71). +//! +//! # Why a store, not a public schema +//! +//! An invitation row carries security-sensitive material — the full minted +//! PASETO invite token — and must never be reachable through the generic +//! entity CRUD API the way `User` or `TenantMembership` are. SchemaForge's +//! backends expose no storage primitive *other* than the schema/entity +//! machinery, so this store reuses that machinery against an internal +//! `ForgeInvitation` table while keeping the boundary at the registry: the +//! provisioning step (in the acton layer) creates the table and persists its +//! metadata for idempotent reboots, but **never inserts it into the in-memory +//! `SchemaRegistry`**. Because `/schemas` and every entity route resolve +//! schemas through that registry, the table is invisible to the public API — +//! a store in every externally observable sense. +//! +//! # Single-use, expiring tokens +//! +//! The emailed accept link carries only the invitation's `jti` (a +//! high-entropy token id minted into the PASETO). On accept, the handler +//! looks the row up by `jti`, *reconstructs the full PASETO from `token`*, +//! verifies it cryptographically, and then checks [`InviteStatus`] and +//! [`ForgeInvitation::is_expired`] before consuming it. Consumption flips the +//! status to [`InviteStatus::Consumed`] so a replayed link is rejected. + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use schema_forge_core::query::{Filter, FieldPath, Query}; +use schema_forge_core::types::{DynamicValue, EntityId, SchemaDefinition, SchemaName}; +use serde::{Deserialize, Serialize}; + +use crate::entity::Entity; +use crate::entity_auth_store::DynEntityStore; +use crate::error::BackendError; + +/// Internal schema backing the invitation store. +/// +/// Parsed and provisioned by the acton layer; intentionally absent from the +/// public schema registry (see the module docs). Timestamps are stored as +/// RFC3339 `text` rather than `datetime` so the store stays fully +/// backend-agnostic and owns its own expiry comparison. +pub const FORGE_INVITATION_SCHEMA: &str = r#" +@system +schema ForgeInvitation { + email: text(max: 512) required indexed + display_name: text(max: 255) + tenant_type: text(max: 128) + tenant_id: text(max: 255) + role: text(max: 128) + jti: text(max: 128) required indexed + token: text(max: 4096) required + status: text(max: 32) required indexed + expires_at: text(max: 64) + invited_by: text(max: 255) + consumed_at: text(max: 64) +} +"#; + +const F_EMAIL: &str = "email"; +const F_DISPLAY_NAME: &str = "display_name"; +const F_TENANT_TYPE: &str = "tenant_type"; +const F_TENANT_ID: &str = "tenant_id"; +const F_ROLE: &str = "role"; +const F_JTI: &str = "jti"; +const F_TOKEN: &str = "token"; +const F_STATUS: &str = "status"; +const F_EXPIRES_AT: &str = "expires_at"; +const F_INVITED_BY: &str = "invited_by"; +const F_CONSUMED_AT: &str = "consumed_at"; + +/// Lifecycle state of an invitation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InviteStatus { + /// Issued and awaiting acceptance. + Pending, + /// Accepted; the link is spent and must not be reusable. + Consumed, + /// Explicitly revoked by an operator before acceptance. + Revoked, +} + +impl InviteStatus { + /// Wire/storage string for the `status` column. + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Consumed => "consumed", + Self::Revoked => "revoked", + } + } + + /// Parse a stored `status` string, returning `None` for unknown values. + pub fn parse(s: &str) -> Option { + match s { + "pending" => Some(Self::Pending), + "consumed" => Some(Self::Consumed), + "revoked" => Some(Self::Revoked), + _ => None, + } + } +} + +/// Fields needed to issue a new invitation. The caller is responsible for +/// minting `jti`/`token` (the PASETO) and choosing `expires_at`. +#[derive(Debug, Clone)] +pub struct NewInvitation { + /// Invitee email (the future `User.email`). + pub email: String, + /// Optional display name to seed onto the pending user. + pub display_name: Option, + /// Tenant root type the invitee is being added to (e.g. `"Organization"`). + pub tenant_type: Option, + /// Tenant root entity id. + pub tenant_id: Option, + /// Role granted within that tenant. + pub role: Option, + /// Token id minted into the PASETO; the opaque value emailed to the user. + pub jti: String, + /// The full minted PASETO invite token, reconstructed and verified on accept. + pub token: String, + /// Absolute expiry. + pub expires_at: DateTime, + /// `sub` of the operator who issued the invite, for audit. + pub invited_by: Option, +} + +/// A persisted invitation row. +#[derive(Debug, Clone)] +pub struct ForgeInvitation { + /// Entity id of the row (used to consume it). + pub id: EntityId, + pub email: String, + pub display_name: Option, + pub tenant_type: Option, + pub tenant_id: Option, + pub role: Option, + pub jti: String, + pub token: String, + pub status: InviteStatus, + pub expires_at: Option>, + pub invited_by: Option, + pub consumed_at: Option>, +} + +impl ForgeInvitation { + /// True if the invitation has passed its expiry as of `now`. + /// + /// A row with an unparseable/absent `expires_at` is treated as expired — + /// fail closed rather than honour an invite with no enforceable deadline. + pub fn is_expired(&self, now: DateTime) -> bool { + match self.expires_at { + Some(exp) => now >= exp, + None => true, + } + } + + /// True if the invitation can still be accepted at `now`. + pub fn is_acceptable(&self, now: DateTime) -> bool { + self.status == InviteStatus::Pending && !self.is_expired(now) + } +} + +/// Storage-agnostic operations over the pending-invitation table. +#[async_trait::async_trait] +pub trait InviteStore: Send + Sync { + /// Persist a new pending invitation. + async fn create(&self, invite: NewInvitation) -> Result; + + /// Look an invitation up by its `jti` (the emailed opaque reference). + async fn find_by_jti(&self, jti: &str) -> Result, BackendError>; + + /// Mark an invitation consumed at `at`. Idempotent at the storage layer; + /// callers enforce single-use by checking [`ForgeInvitation::is_acceptable`] + /// before calling. + async fn mark_consumed(&self, id: &EntityId, at: DateTime) + -> Result<(), BackendError>; +} + +/// [`InviteStore`] backed by the internal `ForgeInvitation` entity table. +/// +/// Mirrors `EntityAuthStore`: holds a type-erased entity store and the +/// resolved `ForgeInvitation` [`SchemaDefinition`] so it can address the +/// table by `SchemaId` without consulting any registry. +pub struct EntityInviteStore { + store: Arc, + schema: SchemaDefinition, +} + +impl EntityInviteStore { + /// Construct the store from the entity-store handle and the parsed + /// `ForgeInvitation` schema definition. + pub fn new(store: Arc, schema: SchemaDefinition) -> Self { + debug_assert_eq!( + schema.name.as_str(), + "ForgeInvitation", + "EntityInviteStore must be constructed with the ForgeInvitation SchemaDefinition" + ); + Self { store, schema } + } + + fn schema_name(&self) -> &SchemaName { + &self.schema.name + } +} + +#[async_trait::async_trait] +impl InviteStore for EntityInviteStore { + async fn create(&self, invite: NewInvitation) -> Result { + let entity = build_invitation_entity(self.schema_name(), &invite); + let created = self.store.create(&entity).await?; + entity_to_invitation(&created).ok_or_else(|| BackendError::Internal { + message: "ForgeInvitation row malformed on readback after create".to_string(), + }) + } + + async fn find_by_jti(&self, jti: &str) -> Result, BackendError> { + let query = Query::new(self.schema.id.clone()) + .with_filter(Filter::eq( + FieldPath::single(F_JTI), + DynamicValue::Text(jti.to_string()), + )) + .with_limit(1); + let result = self.store.query(&query).await?; + Ok(result.entities.iter().find_map(entity_to_invitation)) + } + + async fn mark_consumed( + &self, + id: &EntityId, + at: DateTime, + ) -> Result<(), BackendError> { + let mut entity = self.store.get(self.schema_name(), id).await?; + entity.fields.insert( + F_STATUS.to_string(), + DynamicValue::Text(InviteStatus::Consumed.as_str().to_string()), + ); + entity.fields.insert( + F_CONSUMED_AT.to_string(), + DynamicValue::Text(at.to_rfc3339()), + ); + self.store.update(&entity).await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Pure entity <-> record mappers (unit-tested) +// --------------------------------------------------------------------------- + +fn text_field(value: Option) -> Option { + value + .filter(|s| !s.is_empty()) + .map(DynamicValue::Text) +} + +fn build_invitation_entity(schema: &SchemaName, invite: &NewInvitation) -> Entity { + let mut fields: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + fields.insert(F_EMAIL.to_string(), DynamicValue::Text(invite.email.clone())); + if let Some(v) = text_field(invite.display_name.clone()) { + fields.insert(F_DISPLAY_NAME.to_string(), v); + } + if let Some(v) = text_field(invite.tenant_type.clone()) { + fields.insert(F_TENANT_TYPE.to_string(), v); + } + if let Some(v) = text_field(invite.tenant_id.clone()) { + fields.insert(F_TENANT_ID.to_string(), v); + } + if let Some(v) = text_field(invite.role.clone()) { + fields.insert(F_ROLE.to_string(), v); + } + fields.insert(F_JTI.to_string(), DynamicValue::Text(invite.jti.clone())); + fields.insert(F_TOKEN.to_string(), DynamicValue::Text(invite.token.clone())); + fields.insert( + F_STATUS.to_string(), + DynamicValue::Text(InviteStatus::Pending.as_str().to_string()), + ); + fields.insert( + F_EXPIRES_AT.to_string(), + DynamicValue::Text(invite.expires_at.to_rfc3339()), + ); + if let Some(v) = text_field(invite.invited_by.clone()) { + fields.insert(F_INVITED_BY.to_string(), v); + } + Entity::new(schema.clone(), fields) +} + +fn extract_text(entity: &Entity, field: &str) -> Option { + match entity.field(field) { + Some(DynamicValue::Text(s)) => Some(s.clone()), + _ => None, + } +} + +fn extract_nonempty(entity: &Entity, field: &str) -> Option { + extract_text(entity, field).filter(|s| !s.is_empty()) +} + +fn parse_ts(entity: &Entity, field: &str) -> Option> { + let raw = extract_nonempty(entity, field)?; + DateTime::parse_from_rfc3339(&raw) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +/// Build a [`ForgeInvitation`] from a stored row. Returns `None` if a required +/// column (`id`, `email`, `jti`, `token`, `status`) is missing or malformed — +/// a half-written row is unusable and must not masquerade as a valid invite. +fn entity_to_invitation(entity: &Entity) -> Option { + let id = entity.id.clone(); + let email = extract_nonempty(entity, F_EMAIL)?; + let jti = extract_nonempty(entity, F_JTI)?; + let token = extract_nonempty(entity, F_TOKEN)?; + let status = InviteStatus::parse(&extract_text(entity, F_STATUS)?)?; + Some(ForgeInvitation { + id, + email, + display_name: extract_nonempty(entity, F_DISPLAY_NAME), + tenant_type: extract_nonempty(entity, F_TENANT_TYPE), + tenant_id: extract_nonempty(entity, F_TENANT_ID), + role: extract_nonempty(entity, F_ROLE), + jti, + token, + status, + expires_at: parse_ts(entity, F_EXPIRES_AT), + invited_by: extract_nonempty(entity, F_INVITED_BY), + consumed_at: parse_ts(entity, F_CONSUMED_AT), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn sample_new() -> NewInvitation { + NewInvitation { + email: "invitee@example.gov".to_string(), + display_name: Some("Pat Invitee".to_string()), + tenant_type: Some("Organization".to_string()), + tenant_id: Some("org_123".to_string()), + role: Some("member".to_string()), + jti: "jti-abc".to_string(), + token: "v4.local.deadbeef".to_string(), + expires_at: Utc::now() + Duration::days(7), + invited_by: Some("user:admin".to_string()), + } + } + + #[test] + fn status_roundtrips() { + for s in [InviteStatus::Pending, InviteStatus::Consumed, InviteStatus::Revoked] { + assert_eq!(InviteStatus::parse(s.as_str()), Some(s)); + } + assert_eq!(InviteStatus::parse("bogus"), None); + } + + #[test] + fn build_then_read_roundtrips_core_fields() { + let schema = SchemaName::new("ForgeInvitation").unwrap(); + let invite = sample_new(); + // `Entity::new` assigns the id the backend would persist. + let entity = build_invitation_entity(&schema, &invite); + let rec = entity_to_invitation(&entity).expect("row should parse"); + assert_eq!(rec.email, "invitee@example.gov"); + assert_eq!(rec.jti, "jti-abc"); + assert_eq!(rec.token, "v4.local.deadbeef"); + assert_eq!(rec.status, InviteStatus::Pending); + assert_eq!(rec.role.as_deref(), Some("member")); + assert_eq!(rec.tenant_type.as_deref(), Some("Organization")); + assert!(rec.expires_at.is_some()); + assert!(rec.consumed_at.is_none()); + } + + #[test] + fn missing_required_field_yields_none() { + let schema = SchemaName::new("ForgeInvitation").unwrap(); + let mut fields = std::collections::BTreeMap::new(); + fields.insert(F_EMAIL.to_string(), DynamicValue::Text("a@b.gov".to_string())); + // No jti/token/status. + let entity = Entity::new(schema.clone(), fields); + assert!(entity_to_invitation(&entity).is_none()); + } + + #[test] + fn expiry_and_acceptability() { + let schema = SchemaName::new("ForgeInvitation").unwrap(); + let mut invite = sample_new(); + invite.expires_at = Utc::now() - Duration::hours(1); + let entity = build_invitation_entity(&schema, &invite); + let rec = entity_to_invitation(&entity).unwrap(); + let now = Utc::now(); + assert!(rec.is_expired(now)); + assert!(!rec.is_acceptable(now)); + } + + #[test] + fn absent_expiry_is_treated_as_expired() { + let rec = ForgeInvitation { + id: EntityId::new("ForgeInvitation"), + email: "a@b.gov".to_string(), + display_name: None, + tenant_type: None, + tenant_id: None, + role: None, + jti: "j".to_string(), + token: "t".to_string(), + status: InviteStatus::Pending, + expires_at: None, + invited_by: None, + consumed_at: None, + }; + assert!(rec.is_expired(Utc::now())); + } +} diff --git a/crates/schema-forge-backend/src/lib.rs b/crates/schema-forge-backend/src/lib.rs index 6ed7047..af21f7f 100644 --- a/crates/schema-forge-backend/src/lib.rs +++ b/crates/schema-forge-backend/src/lib.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod entity; pub mod entity_auth_store; pub mod error; +pub mod invite_store; pub mod tenant; pub mod traits; pub mod user_store; @@ -10,6 +11,10 @@ pub use auth::{RecordAccessPolicy, PLATFORM_ADMIN_ROLE}; pub use entity::{Entity, QueryResult}; pub use entity_auth_store::{compute_role_rank, DynEntityStore, EntityAuthStore}; pub use error::BackendError; +pub use invite_store::{ + EntityInviteStore, ForgeInvitation, InviteStatus, InviteStore, NewInvitation, + FORGE_INVITATION_SCHEMA, +}; pub use tenant::TenantRef; pub use tenant::{TenantConfig, TenantConfigError, TenantLevel}; pub use traits::{EntityStore, SchemaBackend}; From 0a447f1cfd4fff69933938e5eaab409620a48d65 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 15:40:07 -0500 Subject: [PATCH 2/6] feat(invite): invite + accept endpoints with boot wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two invite endpoints under /api/v1/forge and wires the supporting state at boot (issue #71): - POST /auth/invites (authenticated): reuses the exact create_user privilege guards — schema-level CreateUser access, platform_admin grant guard, and the Cedar role_rank guard against a synthetic User carrying the proposed role — so an invite can never confer access the inviter lacks. Mints the PASETO invite, persists it, and emails an accept link. Fails closed on delivery error (invite stays Pending, retryable). - POST /auth/invites/accept (public): looks up the pending invite by its opaque id, reconstructs and re-verifies the full token from the stored row (signed claims authoritative over DB columns), creates the User and any TenantMembership, then consumes the invite last so a partial failure leaves it retryable rather than burnt. Added to the token middleware public_paths since the invitee has no bearer. Supporting changes: - AuthStore/DynAuthStore gain add_tenant_membership; EntityAuthStore writes a TenantMembership row referencing the user entity. - email.rs: DisabledEmailSender (fail-closed) injected when SMTP is off. - system.rs: provision_invite_store creates the ForgeInvitation table WITHOUT registering it in the SchemaRegistry, keeping it off the public API. - serve.rs builds the PasetoAuth validator, invite store, and email sender and layers them onto the versioned router. - users.rs guard/audit helpers promoted to pub(crate) for reuse. 900 tests pass; clippy clean. --- crates/schema-forge-acton/src/email.rs | 44 ++ crates/schema-forge-acton/src/routes/auth.rs | 8 + .../schema-forge-acton/src/routes/invites.rs | 523 ++++++++++++++++++ crates/schema-forge-acton/src/routes/mod.rs | 1 + crates/schema-forge-acton/src/routes/users.rs | 14 +- crates/schema-forge-acton/src/shared_auth.rs | 10 + crates/schema-forge-acton/src/state.rs | 26 + crates/schema-forge-acton/src/system.rs | 51 ++ .../src/entity_auth_store.rs | 49 ++ crates/schema-forge-backend/src/user_store.rs | 20 + crates/schema-forge-cli/src/commands/serve.rs | 108 ++++ 11 files changed, 847 insertions(+), 7 deletions(-) create mode 100644 crates/schema-forge-acton/src/routes/invites.rs diff --git a/crates/schema-forge-acton/src/email.rs b/crates/schema-forge-acton/src/email.rs index 72e6b5e..3129d4b 100644 --- a/crates/schema-forge-acton/src/email.rs +++ b/crates/schema-forge-acton/src/email.rs @@ -262,6 +262,36 @@ impl EmailSender for InMemoryEmailSender { } } +/// An [`EmailSender`] that always refuses delivery with +/// [`EmailError::NotConfigured`]. +/// +/// Wired when `[schema_forge.email] enabled = false` so flows that need to +/// send mail get a clear "email not configured" error from `send` rather than +/// a confusing 500 on a missing `Extension>`. The +/// configured `public_base_url` (if any) is still surfaced so link-building +/// code paths behave identically regardless of whether SMTP is wired. +pub struct DisabledEmailSender { + public_base_url: Option, +} + +impl DisabledEmailSender { + /// Create a disabled sender carrying an optional base URL. + pub fn new(public_base_url: Option) -> Self { + Self { public_base_url } + } +} + +#[async_trait] +impl EmailSender for DisabledEmailSender { + async fn send(&self, _message: EmailMessage) -> Result<(), EmailError> { + Err(EmailError::NotConfigured) + } + + fn public_base_url(&self) -> Option<&str> { + self.public_base_url.as_deref() + } +} + #[cfg(test)] mod tests { use super::*; @@ -342,4 +372,18 @@ mod tests { assert_eq!(sent[0].to, "user@example.gov"); assert_eq!(sender.public_base_url(), Some("https://app.agency.gov")); } + + #[tokio::test] + async fn disabled_sender_fails_closed_but_keeps_base_url() { + let sender = DisabledEmailSender::new(Some("https://app.agency.gov".to_string())); + let res = sender + .send(EmailMessage { + to: "user@example.gov".to_string(), + subject: "Welcome".to_string(), + body_text: "hello".to_string(), + }) + .await; + assert!(matches!(res, Err(EmailError::NotConfigured))); + assert_eq!(sender.public_base_url(), Some("https://app.agency.gov")); + } } diff --git a/crates/schema-forge-acton/src/routes/auth.rs b/crates/schema-forge-acton/src/routes/auth.rs index 76f92dd..a050bfc 100644 --- a/crates/schema-forge-acton/src/routes/auth.rs +++ b/crates/schema-forge-acton/src/routes/auth.rs @@ -667,6 +667,14 @@ pub fn auth_routes( .route("/auth/login", post(login)) .route("/auth/refresh", post(refresh)) .route("/auth/me", get(me)) + // Invitations (issue #71). `/auth/invites` is authenticated; + // `/auth/invites/accept` is public (added to the token middleware's + // public_paths by `commands::serve`) since the invitee has no token. + .route("/auth/invites", post(crate::routes::invites::create_invite)) + .route( + "/auth/invites/accept", + post(crate::routes::invites::accept_invite), + ) } // --------------------------------------------------------------------------- diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs new file mode 100644 index 0000000..7f880d7 --- /dev/null +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -0,0 +1,523 @@ +//! User invitation endpoints (issue #71). +//! +//! Two routes, both nested under `/api/v1/forge/`: +//! +//! - `POST /auth/invites` (authenticated) — an operator invites an address +//! into the deployment, optionally scoping the invitee to a tenant and a +//! role. The same privilege guards that gate `POST /users` run here, *at +//! invite time*, so an invite can never grant access the inviter could not +//! grant directly. The minted invitation is persisted and an accept link is +//! emailed to the invitee. +//! +//! - `POST /auth/invites/accept` (public) — the invitee presents the opaque +//! `invite_id` from their email plus a chosen password. The server +//! reconstructs the full PASETO from the stored row, re-verifies it (the +//! *signed* claims are authoritative over the database columns), creates the +//! `User` account and any `TenantMembership` in the same request, then marks +//! the invitation consumed so the link cannot be replayed. +//! +//! # Why create the account at accept, not at invite +//! +//! Creating the `User` only when the invitee accepts avoids leaving a +//! half-provisioned, password-less account sitting in the table between invite +//! and acceptance. The invitation row *is* the pending state. The privilege +//! checks still happen at invite time against the proposed role, so deferring +//! account creation does not weaken authorization. +//! +//! The account creation and the membership write are two store calls with no +//! spanning transaction primitive available. We order them so the invitation +//! is consumed **last**: a failure between the two writes leaves the invite +//! `Pending` (retryable) rather than burning a link against a partially +//! created account. + +use std::sync::Arc; +use std::time::Duration; + +use acton_service::audit::AuditSeverity; +use acton_service::auth::tokens::paseto_generator::PasetoGenerator; +use acton_service::middleware::paseto::PasetoAuth; +use acton_service::state::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Extension, Json}; +use chrono::Utc; +use schema_forge_backend::user_store::ForgeUser; +use schema_forge_backend::{InviteStore, NewInvitation}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; + +use crate::access::{check_schema_access, AccessAction, OptionalClaims}; +use crate::authz::engine::authorize; +use crate::authz::namespace::ActionVerb; +use crate::config::SchemaForgeConfig; +use crate::email::{EmailMessage, EmailSender}; +use crate::error::ForgeError; +use crate::invite::{mint_invite_token, verify_invite_token, InviteTokenParams}; +use crate::routes::users::{ + audit_user, caller_can_grant_roles, fetch_policy_store, fetch_user_schema, + forge_user_to_user_entity, require_auth, validate_password, +}; +use crate::state::DynAuthStore; + +/// How long a minted invitation stays valid (7 days). +/// +/// Long enough to survive a weekend and a missed inbox, short enough that a +/// leaked link expires on a human timescale. Sets both the PASETO `exp` and +/// the stored `expires_at`. +pub const INVITE_TOKEN_LIFETIME: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +const MAX_EMAIL_LEN: usize = 512; + +// --------------------------------------------------------------------------- +// Wire shapes +// --------------------------------------------------------------------------- + +/// Request body for `POST /auth/invites`. +#[derive(Debug, Deserialize)] +pub struct CreateInviteRequest { + /// Invitee email. Becomes the future `User.email` (the login identifier). + pub email: String, + /// Optional display name to seed onto the future account. + #[serde(default)] + pub display_name: Option, + /// Tenant root type the invitee is being added to (e.g. `"Organization"`). + #[serde(default)] + pub tenant_type: Option, + /// Tenant root entity id. + #[serde(default)] + pub tenant_id: Option, + /// Role granted to the invitee — used as both their `User` role and their + /// `TenantMembership` role for this slice. + #[serde(default)] + pub role: Option, +} + +/// Success body for `POST /auth/invites`. +#[derive(Debug, Serialize)] +pub struct CreateInviteResponse { + /// The opaque invitation reference emailed to the invitee. + pub invite_id: String, + /// The invited email. + pub email: String, + /// ISO-8601 UTC expiry. + pub expires_at: String, +} + +/// Request body for `POST /auth/invites/accept`. +#[derive(Debug, Deserialize)] +pub struct AcceptInviteRequest { + /// The opaque invitation reference from the emailed link. + pub invite_id: String, + /// The password the invitee chooses for their new account. + pub password: String, + /// Optional display name override; falls back to the invite's, then email. + #[serde(default)] + pub display_name: Option, +} + +/// Success body for `POST /auth/invites/accept`. +#[derive(Debug, Serialize)] +pub struct AcceptInviteResponse { + /// The created account's login identifier (its email). + pub email: String, + /// Roles granted to the new account. + pub roles: Vec, +} + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested) +// --------------------------------------------------------------------------- + +/// Minimal structural email validation. Not RFC 5322 — just enough to reject +/// obvious garbage before minting a token and writing a row. The real proof an +/// address is deliverable is the invitee receiving and clicking the link. +fn validate_email(email: &str) -> Result<(), ForgeError> { + let invalid = |msg: &str| { + Err(ForgeError::ValidationFailed { + details: vec![msg.to_string()], + }) + }; + if email.is_empty() { + return invalid("email must not be empty"); + } + if email.len() > MAX_EMAIL_LEN { + return invalid("email exceeds maximum length of 512"); + } + if email.chars().any(char::is_whitespace) { + return invalid("email must not contain whitespace"); + } + let Some((local, domain)) = email.split_once('@') else { + return invalid("email must contain exactly one '@'"); + }; + if local.is_empty() || domain.is_empty() { + return invalid("email must have text on both sides of '@'"); + } + if domain.contains('@') { + return invalid("email must contain exactly one '@'"); + } + if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') { + return invalid("email domain must contain a dot"); + } + Ok(()) +} + +/// Build the absolute accept link the invitee clicks. Falls back to a +/// site-relative path when no public base URL is configured. +fn build_accept_url(base: Option<&str>, invite_id: &str) -> String { + match base { + Some(b) => format!("{}/invite/accept?invite={invite_id}", b.trim_end_matches('/')), + None => format!("/invite/accept?invite={invite_id}"), + } +} + +/// Plain-text invitation email body. +fn invite_email_body(accept_url: &str) -> String { + format!( + "You have been invited to join the SchemaForge workspace.\n\n\ + To accept the invitation and set your password, open:\n\n {accept_url}\n\n\ + If you were not expecting this invitation you can ignore this message.\n" + ) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /auth/invites` — issue an invitation. +/// +/// Authorization mirrors `POST /users` exactly: schema-level `CreateUser` +/// access via [`check_schema_access`], the `platform_admin`-grant guard via +/// [`caller_can_grant_roles`], and the Cedar `role_rank` guard against a +/// synthetic `User` carrying the proposed role. Running these here means an +/// invitation can never confer access the inviter lacks the authority to +/// grant directly. +#[instrument(skip_all)] +pub async fn create_invite( + State(state): State>, + Extension(auth_store): Extension>, + Extension(generator): Extension>, + Extension(email_sender): Extension>, + Extension(invite_store): Extension>, + OptionalClaims(claims): OptionalClaims, + Json(body): Json, +) -> Result { + let claims = require_auth(&claims)?; + let user_schema = fetch_user_schema(&state).await?; + let policy_store = fetch_policy_store(&state).await?; + + check_schema_access(&policy_store, &user_schema, Some(claims), AccessAction::Create)?; + + validate_email(&body.email)?; + + // The invite grants a single role (or none). Apply the same role-grant + // guards `create_user` applies to its `roles` list. + let proposed_roles: Vec = body + .role + .iter() + .filter(|r| !r.is_empty()) + .cloned() + .collect(); + caller_can_grant_roles(claims, &proposed_roles)?; + + let proposed = ForgeUser { + username: body.email.clone(), + roles: proposed_roles.clone(), + display_name: body.display_name.clone(), + active: true, + role_rank: 0, + }; + let proposed_entity = forge_user_to_user_entity(&proposed, policy_store.as_ref()); + let decision = authorize( + &policy_store, + Some(claims), + ActionVerb::Create, + &user_schema, + Some(&proposed_entity), + ) + .map_err(|e| ForgeError::Internal { + message: format!("authz engine error during create_invite: {e}"), + })?; + if !decision.is_allow() { + audit_user( + &state, + "forge.access.denied", + AuditSeverity::Warning, + &claims.sub, + &body.email, + Some(serde_json::json!({ + "action": "create_invite", + "proposed_roles": proposed_roles, + "reason": "role_rank_guard", + })), + ) + .await; + return Err(ForgeError::Forbidden { + message: format!( + "inviting a user with roles {proposed_roles:?} would exceed caller's role_rank" + ), + }); + } + + // Refuse to invite an address that already has an account. + if auth_store.get_user(&body.email).await?.is_some() { + return Err(ForgeError::ValidationFailed { + details: vec![format!("user '{}' already exists", body.email)], + }); + } + + let minted = mint_invite_token( + &generator, + &InviteTokenParams { + email: body.email.clone(), + tenant_type: body.tenant_type.clone(), + tenant_id: body.tenant_id.clone(), + role: body.role.clone(), + }, + INVITE_TOKEN_LIFETIME, + ) + .map_err(|e| ForgeError::Internal { + message: format!("failed to mint invite token: {e}"), + })?; + + // Persist before emailing so the accept link always resolves to a row. + let invitation = invite_store + .create(NewInvitation { + email: body.email.clone(), + display_name: body.display_name.clone(), + tenant_type: body.tenant_type.clone(), + tenant_id: body.tenant_id.clone(), + role: body.role.clone(), + jti: minted.invite_id.clone(), + token: minted.token.clone(), + expires_at: minted.expires_at, + invited_by: Some(claims.sub.clone()), + }) + .await?; + + // Deliver the accept link. Fail closed: a delivery failure surfaces as a + // 5xx so the operator knows the invite did not reach the invitee. The row + // stays `Pending`, so the invite can be re-sent once SMTP is healthy. + let accept_url = build_accept_url(email_sender.public_base_url(), &invitation.jti); + let message = EmailMessage { + to: body.email.clone(), + subject: "You've been invited".to_string(), + body_text: invite_email_body(&accept_url), + }; + if let Err(e) = email_sender.send(message).await { + audit_user( + &state, + "forge.invite.send_failed", + AuditSeverity::Error, + &claims.sub, + &body.email, + Some(serde_json::json!({ + "invite_id": invitation.jti, + "error": e.to_string(), + })), + ) + .await; + return Err(ForgeError::Internal { + message: format!("invitation stored but email delivery failed: {e}"), + }); + } + + audit_user( + &state, + "forge.invite.created", + AuditSeverity::Notice, + &claims.sub, + &body.email, + Some(serde_json::json!({ + "invite_id": invitation.jti, + "tenant_type": body.tenant_type, + "tenant_id": body.tenant_id, + "role": body.role, + })), + ) + .await; + + Ok(( + StatusCode::CREATED, + Json(CreateInviteResponse { + invite_id: invitation.jti, + email: body.email, + expires_at: minted.expires_at.to_rfc3339(), + }), + )) +} + +/// `POST /auth/invites/accept` — accept an invitation and onboard the user. +/// +/// Public (the invitee has no account yet); gated entirely by possession of a +/// valid, unexpired, unconsumed invitation. The full token never leaves the +/// server — the client sends only the opaque `invite_id`, and the server +/// reconstructs the PASETO from the stored row and re-verifies it. Role and +/// tenant are read from the **signed** claims, defending against tampering +/// with the stored columns. +#[instrument(skip_all)] +pub async fn accept_invite( + State(state): State>, + Extension(auth_store): Extension>, + Extension(invite_store): Extension>, + Extension(validator): Extension>, + Json(body): Json, +) -> Result { + validate_password(&body.password)?; + + let invite = invite_store + .find_by_jti(&body.invite_id) + .await? + .ok_or_else(|| ForgeError::ValidationFailed { + details: vec!["invitation not found".to_string()], + })?; + + let now = Utc::now(); + if !invite.is_acceptable(now) { + let reason = if invite.is_expired(now) { + "expired" + } else { + "not_pending" + }; + audit_user( + &state, + "forge.invite.rejected", + AuditSeverity::Warning, + "anonymous", + &invite.email, + Some(serde_json::json!({ "invite_id": body.invite_id, "reason": reason })), + ) + .await; + return Err(ForgeError::ValidationFailed { + details: vec!["invitation is expired or already used".to_string()], + }); + } + + // Reconstruct + verify the full token from the stored column. Signed + // claims are authoritative; the DB columns are a convenience mirror. + let verified = + verify_invite_token(validator.as_ref(), &invite.token).map_err(|e| ForgeError::Internal { + message: format!("stored invite token failed verification: {e}"), + })?; + if verified.invite_id != invite.jti { + return Err(ForgeError::Internal { + message: "invite token does not match the invitation it was stored under".to_string(), + }); + } + + if auth_store.get_user(&verified.email).await?.is_some() { + return Err(ForgeError::Conflict { + reason: "user_exists", + message: format!("user '{}' already exists", verified.email), + }); + } + + let roles: Vec = verified + .role + .iter() + .filter(|r| !r.is_empty()) + .cloned() + .collect(); + let display_name = body + .display_name + .clone() + .or_else(|| invite.display_name.clone()) + .unwrap_or_else(|| verified.email.clone()); + + // Create the account, then grant the membership, then consume the invite + // (consumed last — see the module docs on the non-atomic write ordering). + auth_store + .create_user(&verified.email, &body.password, &roles, &display_name) + .await?; + + if let (Some(tt), Some(tid)) = (verified.tenant_type.as_deref(), verified.tenant_id.as_deref()) + { + auth_store + .add_tenant_membership(&verified.email, tt, tid, verified.role.as_deref()) + .await?; + } + + invite_store.mark_consumed(&invite.id, now).await?; + + audit_user( + &state, + "forge.invite.accepted", + AuditSeverity::Notice, + &verified.email, + &verified.email, + Some(serde_json::json!({ + "invite_id": invite.jti, + "roles": roles, + "tenant_type": verified.tenant_type, + "tenant_id": verified.tenant_id, + })), + ) + .await; + + Ok(( + StatusCode::CREATED, + Json(AcceptInviteResponse { + email: verified.email, + roles, + }), + )) +} + +// --------------------------------------------------------------------------- +// Unit tests for pure helpers +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_email_accepts_plausible_addresses() { + assert!(validate_email("invitee@example.gov").is_ok()); + assert!(validate_email("a.b-c+tag@sub.agency.gov").is_ok()); + } + + #[test] + fn validate_email_rejects_garbage() { + assert!(validate_email("").is_err()); + assert!(validate_email("no-at-sign").is_err()); + assert!(validate_email("two@@at.gov").is_err()); + assert!(validate_email("nodot@localhost").is_err()); + assert!(validate_email("has space@x.gov").is_err()); + assert!(validate_email("@x.gov").is_err()); + assert!(validate_email("user@").is_err()); + assert!(validate_email("user@x.").is_err()); + } + + #[test] + fn validate_email_rejects_overlong() { + let long = format!("{}@x.gov", "a".repeat(MAX_EMAIL_LEN)); + assert!(validate_email(&long).is_err()); + } + + #[test] + fn build_accept_url_uses_base_and_trims_trailing_slash() { + assert_eq!( + build_accept_url(Some("https://app.agency.gov/"), "abc123"), + "https://app.agency.gov/invite/accept?invite=abc123" + ); + assert_eq!( + build_accept_url(Some("https://app.agency.gov"), "abc123"), + "https://app.agency.gov/invite/accept?invite=abc123" + ); + } + + #[test] + fn build_accept_url_falls_back_to_relative_path() { + assert_eq!( + build_accept_url(None, "abc123"), + "/invite/accept?invite=abc123" + ); + } + + #[test] + fn invite_email_body_contains_link() { + let body = invite_email_body("https://app.agency.gov/invite/accept?invite=xyz"); + assert!(body.contains("https://app.agency.gov/invite/accept?invite=xyz")); + } +} diff --git a/crates/schema-forge-acton/src/routes/mod.rs b/crates/schema-forge-acton/src/routes/mod.rs index 712c3a3..d2f54eb 100644 --- a/crates/schema-forge-acton/src/routes/mod.rs +++ b/crates/schema-forge-acton/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod entities; pub mod files; pub mod health; +pub mod invites; pub mod meta; pub mod permissions; pub mod query_params; diff --git a/crates/schema-forge-acton/src/routes/users.rs b/crates/schema-forge-acton/src/routes/users.rs index 4dbed8a..702c002 100644 --- a/crates/schema-forge-acton/src/routes/users.rs +++ b/crates/schema-forge-acton/src/routes/users.rs @@ -62,7 +62,7 @@ use acton_service::prelude::ActorHandleInterface; /// The acton-service audit chain owns sequencing and BLAKE3 hash linkage; /// this helper centralises severity selection and the `actor / target / /// action` metadata shape so every emission stays consistent. -async fn audit_user( +pub(crate) async fn audit_user( state: &AppState, event: &'static str, severity: AuditSeverity, @@ -123,7 +123,7 @@ async fn audit_password_changed( /// Duplicated (by design) from `routes::schemas` because that file is owned /// by a parallel agent and cannot be edited. The helper is trivially pure /// and unit-tested below. -fn require_auth(claims: &Option) -> Result<&Claims, ForgeError> { +pub(crate) fn require_auth(claims: &Option) -> Result<&Claims, ForgeError> { claims.as_ref().ok_or(ForgeError::Unauthorized { message: "authentication required".to_string(), }) @@ -131,7 +131,7 @@ fn require_auth(claims: &Option) -> Result<&Claims, ForgeError> { /// Fetch the User schema definition from the registry. -async fn fetch_user_schema( +pub(crate) async fn fetch_user_schema( state: &AppState, ) -> Result { let forge = state @@ -155,7 +155,7 @@ async fn fetch_user_schema( } /// Fetch the current Cedar policy store from the actor. -async fn fetch_policy_store( +pub(crate) async fn fetch_policy_store( state: &AppState, ) -> Result, ForgeError> { let forge = state @@ -188,7 +188,7 @@ async fn fetch_policy_store( /// table the policy store already carries. This makes the global /// `user_role_rank_forbid` policy fire correctly without requiring a /// separate read of the User table. -fn forge_user_to_user_entity(user: &ForgeUser, store: &PolicyStore) -> Entity { +pub(crate) fn forge_user_to_user_entity(user: &ForgeUser, store: &PolicyStore) -> Entity { let snapshot = store.current(); let role_rank = snapshot.role_ranks.max_rank(&user.roles); @@ -237,7 +237,7 @@ fn forge_user_to_user_entity(user: &ForgeUser, store: &PolicyStore) -> Entity { /// The only restricted role today is `platform_admin`: only an existing /// platform admin may grant it. Other role names pass through. Same /// helper will gate role edits in a future `PUT /users/:username`. -fn caller_can_grant_roles( +pub(crate) fn caller_can_grant_roles( claims: &Claims, requested_roles: &[String], ) -> Result<(), ForgeError> { @@ -290,7 +290,7 @@ fn validate_username(username: &str) -> Result<(), ForgeError> { } /// Verify a plaintext password meets the minimum length requirement. -fn validate_password(password: &str) -> Result<(), ForgeError> { +pub(crate) fn validate_password(password: &str) -> Result<(), ForgeError> { if password.is_empty() { return Err(ForgeError::ValidationFailed { details: vec!["password must not be empty".to_string()], diff --git a/crates/schema-forge-acton/src/shared_auth.rs b/crates/schema-forge-acton/src/shared_auth.rs index 4b268b3..c4fd6e1 100644 --- a/crates/schema-forge-acton/src/shared_auth.rs +++ b/crates/schema-forge-acton/src/shared_auth.rs @@ -256,6 +256,16 @@ mod tests { ) -> Result, BackendError> { unimplemented!("not used by bootstrap path") } + + async fn add_tenant_membership( + &self, + _username: &str, + _tenant_type: &str, + _tenant_id: &str, + _role: Option<&str>, + ) -> Result<(), BackendError> { + unimplemented!("not used by bootstrap path") + } } /// Helper: run an async block on a single-threaded current-thread runtime. diff --git a/crates/schema-forge-acton/src/state.rs b/crates/schema-forge-acton/src/state.rs index 55672b8..af86e4a 100644 --- a/crates/schema-forge-acton/src/state.rs +++ b/crates/schema-forge-acton/src/state.rs @@ -312,6 +312,16 @@ pub trait DynAuthStore: Send + Sync { &'a self, username: &'a str, ) -> Pin, BackendError>> + Send + 'a>>; + + /// Grant the user a tenant membership. See + /// [`schema_forge_backend::user_store::AuthStore::add_tenant_membership`]. + fn add_tenant_membership<'a>( + &'a self, + username: &'a str, + tenant_type: &'a str, + tenant_id: &'a str, + role: Option<&'a str>, + ) -> Pin> + Send + 'a>>; } /// Blanket impl: any concrete `AuthStore` automatically implements `DynAuthStore`. @@ -411,6 +421,22 @@ impl DynAuthStore for T { ) -> Pin, BackendError>> + Send + 'a>> { Box::pin(AuthStore::list_tenant_memberships(self, username)) } + + fn add_tenant_membership<'a>( + &'a self, + username: &'a str, + tenant_type: &'a str, + tenant_id: &'a str, + role: Option<&'a str>, + ) -> Pin> + Send + 'a>> { + Box::pin(AuthStore::add_tenant_membership( + self, + username, + tenant_type, + tenant_id, + role, + )) + } } // --------------------------------------------------------------------------- diff --git a/crates/schema-forge-acton/src/system.rs b/crates/schema-forge-acton/src/system.rs index 43347ce..334eb4e 100644 --- a/crates/schema-forge-acton/src/system.rs +++ b/crates/schema-forge-acton/src/system.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; use schema_forge_core::migration::DiffEngine; use schema_forge_core::system_schemas; @@ -108,6 +109,56 @@ pub async fn seed_system_schemas_into_map( Ok(()) } +/// Provision the internal `ForgeInvitation` table and return a ready +/// [`InviteStore`](schema_forge_backend::InviteStore) over it. +/// +/// Deliberately mirrors [`seed_system_schemas`] **minus the registry insert**: +/// the table is created and its metadata persisted (so reboots are idempotent +/// and entity queries resolve the right `SchemaId`), but it is never added to +/// the in-memory [`SchemaRegistry`]. Because `/schemas` and every entity route +/// resolve schemas through that registry, the invitation table — which holds +/// the full minted invite tokens — stays unreachable from the public API. See +/// the module docs on [`schema_forge_backend::invite_store`]. +pub async fn provision_invite_store( + backend: &dyn DynForgeBackend, + entity_store: Arc, +) -> Result, ForgeError> { + let mut definitions = schema_forge_dsl::parse(schema_forge_backend::FORGE_INVITATION_SCHEMA) + .map_err(|errors| ForgeError::Internal { + message: format!("failed to parse ForgeInvitation schema: {errors:?}"), + })?; + let definition = definitions.pop().ok_or_else(|| ForgeError::Internal { + message: "ForgeInvitation schema parsed to zero definitions".to_string(), + })?; + + // Reuse the persisted definition when present so the store addresses the + // table by the same SchemaId the backend already provisioned. + let definition = match backend + .load_schema_metadata(&definition.name) + .await + .map_err(ForgeError::from)? + { + Some(existing) => existing, + None => { + let plan = DiffEngine::create_new(&definition); + backend + .apply_migration(&definition.name, &plan.steps) + .await + .map_err(ForgeError::from)?; + backend + .store_schema_metadata(&definition) + .await + .map_err(ForgeError::from)?; + definition + } + }; + + Ok(Arc::new(schema_forge_backend::EntityInviteStore::new( + entity_store, + definition, + ))) +} + #[cfg(test)] mod tests { use schema_forge_core::system_schemas; diff --git a/crates/schema-forge-backend/src/entity_auth_store.rs b/crates/schema-forge-backend/src/entity_auth_store.rs index 18b1a6d..dc0dd5c 100644 --- a/crates/schema-forge-backend/src/entity_auth_store.rs +++ b/crates/schema-forge-backend/src/entity_auth_store.rs @@ -47,6 +47,7 @@ const LAST_LOGIN_FIELD: &str = "last_login"; const TM_USER_FIELD: &str = "user"; const TM_TENANT_TYPE_FIELD: &str = "tenant_type"; const TM_TENANT_ID_FIELD: &str = "tenant_id"; +const TM_ROLE_FIELD: &str = "role"; /// Function that maps a role name to a numeric rank, returning `None` /// for unregistered roles. @@ -608,6 +609,54 @@ impl AuthStore for EntityAuthStore { .collect()) } + async fn add_tenant_membership( + &self, + username: &str, + tenant_type: &str, + tenant_id: &str, + role: Option<&str>, + ) -> Result<(), BackendError> { + let tm_schema = + self.tenant_membership_schema + .as_ref() + .ok_or_else(|| BackendError::Internal { + message: "TenantMembership schema not attached; cannot grant membership" + .to_string(), + })?; + + // Resolve the user's EntityId so the membership row references the + // actual `User` entity — the `user` field is a typed `-> User` ref, + // and `list_tenant_memberships` filters on `DynamicValue::Ref(id)`. + let user_entity = self.find_entity_by_username(username).await?.ok_or_else(|| { + BackendError::EntityNotFound { + schema: "User".to_string(), + entity_id: username.to_string(), + } + })?; + + let mut fields: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + fields.insert( + TM_USER_FIELD.to_string(), + DynamicValue::Ref(user_entity.id.clone()), + ); + fields.insert( + TM_TENANT_TYPE_FIELD.to_string(), + DynamicValue::Text(tenant_type.to_string()), + ); + fields.insert( + TM_TENANT_ID_FIELD.to_string(), + DynamicValue::Text(tenant_id.to_string()), + ); + if let Some(r) = role.filter(|s| !s.is_empty()) { + fields.insert(TM_ROLE_FIELD.to_string(), DynamicValue::Text(r.to_string())); + } + + let entity = Entity::new(tm_schema.name.clone(), fields); + self.store.create(&entity).await?; + Ok(()) + } + async fn record_login( &self, username: &str, diff --git a/crates/schema-forge-backend/src/user_store.rs b/crates/schema-forge-backend/src/user_store.rs index 62ff1d5..7033038 100644 --- a/crates/schema-forge-backend/src/user_store.rs +++ b/crates/schema-forge-backend/src/user_store.rs @@ -127,6 +127,26 @@ pub trait AuthStore: Send + Sync { username: &str, at: DateTime, ) -> impl Future> + Send; + + /// Grant `username` a membership in the tenant identified by + /// `(tenant_type, tenant_id)`, optionally scoped to `role`. + /// + /// Writes one `TenantMembership` row referencing the user's `User` + /// entity. Used by the invite-accept flow (issue #71) to make a freshly + /// onboarded user a member of the tenant they were invited to, in the + /// same request that creates their account. + /// + /// Errors if the user row cannot be resolved or the deployment has not + /// seeded the `TenantMembership` schema — onboarding a user into a + /// tenant that the store can't record must fail loudly, not silently + /// drop the membership. + fn add_tenant_membership( + &self, + username: &str, + tenant_type: &str, + tenant_id: &str, + role: Option<&str>, + ) -> impl Future> + Send; } #[cfg(test)] diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 90eaf33..4caa5f0 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -4,6 +4,7 @@ use std::time::Duration; use acton_service::auth::config::{PasetoGenerationConfig, TokenGenerationConfig}; use acton_service::auth::tokens::paseto_generator::PasetoGenerator; +use acton_service::middleware::paseto::PasetoAuth; use acton_service::prelude::ActorHandleInterface; use acton_service::service_builder::ServiceBuilder; use acton_service::versioning::{ApiVersion, VersionedApiBuilder}; @@ -303,6 +304,10 @@ pub async fn run( if let Some(acton_service::config::TokenConfig::Paseto(ref mut pc)) = svc_config.token { pc.public_paths.push("/api/v1/forge/auth/login".to_string()); pc.public_paths.push("/api/v1/forge/meta".to_string()); + // The invitee has no token yet; the accept endpoint authenticates by + // possession of a valid, unconsumed invitation, not a bearer. + pc.public_paths + .push("/api/v1/forge/auth/invites/accept".to_string()); } // Opt-in permissive CORS for local development. Warns loudly in logs. @@ -323,6 +328,41 @@ pub async fn run( // auto-created on first boot when missing so `serve` is self-bootstrapping. let paseto_generator = build_paseto_generator(&svc_config, output)?; + // Build the PASETO *validator* from the same config/key the generator + // uses. The invite-accept endpoint re-verifies the stored invite token + // through this validator so role/tenant are read from signed claims. + let paseto_validator = build_paseto_validator(&svc_config)?; + + // Provision the internal ForgeInvitation table (NOT registered in the + // public schema registry) and build the invite store over it. + let invite_store = schema_forge_acton::system::provision_invite_store( + backend_arc.as_ref(), + entity_store.clone(), + ) + .await + .map_err(|e| CliError::Server { + message: format!("failed to provision invite store: {e}"), + })?; + + // Build the outbound email transport. When `[schema_forge.email]` is + // disabled we still inject a sender — one that fails closed — so the + // invite endpoints return a clear "email not configured" error rather + // than 500-ing on a missing extension. + let email_cfg = svc_config.custom.schema_forge.email.clone(); + let email_sender: Arc = if email_cfg.enabled { + Arc::new( + schema_forge_acton::email::SmtpEmailSender::from_config(&email_cfg).map_err(|e| { + CliError::Config { + message: format!("invalid [schema_forge.email] config: {e}"), + } + })?, + ) + } else { + Arc::new(schema_forge_acton::email::DisabledEmailSender::new( + email_cfg.public_base_url.clone(), + )) + }; + // 8. Build versioned routes via acton-service for the JSON forge API. // Build a `MetaInfo` snapshot from the resolved DB params + the // login token TTL so `GET /api/v1/forge/meta` can surface honest @@ -338,6 +378,9 @@ pub async fn run( let routes = build_versioned_routes( login_auth_store, paseto_generator, + paseto_validator, + invite_store, + email_sender, meta_info, resolved_principal_claims, tenant_config_layer, @@ -672,9 +715,13 @@ fn build_paseto_generator( /// Nests SchemaForge's JSON API routes under `/api/v1/forge/`. All UI /// surfaces are generated client-side by `schemaforge site generate`; this /// server only serves the JSON API plus the login endpoint. +#[allow(clippy::too_many_arguments)] fn build_versioned_routes( auth_store: Arc, paseto_generator: Arc, + paseto_validator: Arc, + invite_store: Arc, + email_sender: Arc, meta_info: Arc, principal_claims: Arc, tenant_config: Arc>, @@ -684,6 +731,9 @@ fn build_versioned_routes( // extract them via axum::Extension. let auth_store_layer = auth_store; let generator_layer = paseto_generator; + let validator_layer = paseto_validator; + let invite_store_layer = invite_store; + let email_sender_layer = email_sender; let meta_layer = meta_info; let principal_claims_layer = principal_claims; let tenant_config_layer = tenant_config; @@ -704,6 +754,9 @@ fn build_versioned_routes( )) .layer(Extension(auth_store_layer)) .layer(Extension(generator_layer)) + .layer(Extension(validator_layer)) + .layer(Extension(invite_store_layer)) + .layer(Extension(email_sender_layer)) .layer(Extension(meta_layer)) .layer(Extension(principal_claims_layer)) .layer(Extension(tenant_config_layer)) @@ -711,6 +764,30 @@ fn build_versioned_routes( .build_routes() } +/// Build a [`PasetoAuth`] validator from the loaded acton-service config. +/// +/// Shares the key file the token middleware and [`build_paseto_generator`] +/// use, so a token minted by the generator round-trips through this validator. +/// Used by the invite-accept endpoint to re-verify a stored invite token. +fn build_paseto_validator( + svc_config: &acton_service::config::Config, +) -> Result, CliError> { + let paseto_cfg = match &svc_config.token { + Some(acton_service::config::TokenConfig::Paseto(pc)) => pc, + _ => { + return Err(CliError::Config { + message: "[token] must be configured with format = \"paseto\" to verify \ + invitation tokens" + .to_string(), + }); + } + }; + let validator = PasetoAuth::new(paseto_cfg).map_err(|e| CliError::Config { + message: format!("failed to build PASETO validator: {e}"), + })?; + Ok(Arc::new(validator)) +} + /// Layer the SchemaForge `/health` override middleware onto the versioned /// routes returned by [`build_versioned_routes`]. /// @@ -873,12 +950,31 @@ mod tests { }; use schema_forge_surrealdb::SurrealBackend; + use std::io::Write as _; + let key = [0u8; 32]; let generator = Arc::new(PasetoGenerator::with_symmetric_key( key, TokenGenerationConfig::default(), )); + // A matching on-disk key so `PasetoAuth::new` can build a validator + // sharing the generator's symmetric key. + let mut key_file = tempfile::NamedTempFile::new().unwrap(); + key_file.write_all(&key).unwrap(); + key_file.flush().unwrap(); + let validator = Arc::new( + PasetoAuth::new(&acton_service::config::PasetoConfig { + version: "v4".to_string(), + purpose: "local".to_string(), + key_path: key_file.path().to_path_buf(), + issuer: None, + audience: None, + public_paths: vec![], + }) + .unwrap(), + ); + let rt = tokio::runtime::Runtime::new().unwrap(); let backend = rt .block_on(SurrealBackend::connect_with_auth( @@ -937,6 +1033,15 @@ mod tests { EntityAuthStore::new(entity_store.clone(), user_schema, resolver), ); + let invite_store = rt + .block_on(schema_forge_acton::system::provision_invite_store( + backend.as_ref(), + entity_store.clone(), + )) + .unwrap(); + let email_sender: Arc = + Arc::new(schema_forge_acton::email::DisabledEmailSender::new(None)); + let meta = Arc::new(schema_forge_acton::MetaInfo::new( "surrealdb", "SurrealDB 2.x", @@ -952,6 +1057,9 @@ mod tests { let _routes = build_versioned_routes( auth_store, generator, + validator, + invite_store, + email_sender, meta, principal_claims, tenant_config, From 8a12a7d4ed27fc51079be1ed74bf55eb207cb8ca Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 16:07:05 -0500 Subject: [PATCH 3/6] feat(invite): accept SMTP password via SCHEMAFORGE_SMTP_PASSWORD env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite email transport needs an SMTP password that must never live in committed TOML. acton-service's ACTON_-prefixed Figment env layering can't target the [schema_forge] section — Env::split("_") shatters the underscore in the section key — so the documented "password via env" path did not actually work for this section. serve.rs now reads a dedicated SCHEMAFORGE_SMTP_PASSWORD env var (matching the existing SCHEMAFORGE_* convention used for token/trust-policy) and overrides EmailConfig.password before building the sender. config.toml documents the [schema_forge.email] section and the env-only password rule. Verified end-to-end against Stalwart (mail.govcraft.ai): invite minted + emailed + delivered, accept created the user + membership, replay rejected (single-use), new user logged in. Secret supplied via env only, confirmed absent from disk. --- config.toml | 14 ++++++++++++++ crates/schema-forge-cli/src/commands/serve.rs | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/config.toml b/config.toml index 07a5c5b..ab7ce6c 100644 --- a/config.toml +++ b/config.toml @@ -15,6 +15,20 @@ issuer = "schema-forge" per_user_rpm = 6000 per_client_rpm = 60000 +# Outbound email (user invitations, issue #71). Disabled by default. +# The SMTP password is NEVER read from this file — supply it at runtime via +# the SCHEMAFORGE_SMTP_PASSWORD environment variable so the secret stays out +# of git. (acton-service's ACTON_-prefixed env layering can't target the +# `[schema_forge]` section, so this dedicated env var is the supported path.) +# [schema_forge.email] +# enabled = true +# host = "mail.example.gov" +# port = 465 # 465 = implicit TLS (default); 587 = STARTTLS +# tls = "implicit" # or "start_tls" +# from = "Example " +# username = "noreply@example.gov" +# public_base_url = "https://app.example.gov" # used to build invite-accept links + # Webhook notification settings # [schema_forge.webhooks] # enabled = true diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 4caa5f0..1bafa35 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -348,7 +348,19 @@ pub async fn run( // disabled we still inject a sender — one that fails closed — so the // invite endpoints return a clear "email not configured" error rather // than 500-ing on a missing extension. - let email_cfg = svc_config.custom.schema_forge.email.clone(); + let mut email_cfg = svc_config.custom.schema_forge.email.clone(); + // The SMTP password must never live in committed TOML. acton-service's + // `ACTON_`-prefixed Figment env layering can't target the `[schema_forge]` + // section — `Env::split("_")` shatters the underscore in the section key + // — so the secret is accepted through a dedicated env var instead, + // matching the `SCHEMAFORGE_*` convention used elsewhere (token, trust + // policy). Set `SCHEMAFORGE_SMTP_PASSWORD` to authenticate the relay. + if let Some(pw) = std::env::var("SCHEMAFORGE_SMTP_PASSWORD") + .ok() + .filter(|p| !p.is_empty()) + { + email_cfg.password = Some(pw); + } let email_sender: Arc = if email_cfg.enabled { Arc::new( schema_forge_acton::email::SmtpEmailSender::from_config(&email_cfg).map_err(|e| { From 508b630d75141ade9873760d46d7c532205ecfc5 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 16:28:21 -0500 Subject: [PATCH 4/6] feat(branding): add [schema_forge] project_name and brand invite emails A SchemaForge deployment had no human-facing name: invitation emails hardcoded "join the SchemaForge workspace" and the From display-name came only from the operator's `from` mailbox, so an app like "Bob's Dog Scheduling" greeted onboarding users with the engine's name. Add a single branding knob, `[schema_forge] project_name` (defaults to "SchemaForge"), and wire it through: - invite email body + subject are branded with project_name - SmtpEmailSender brands a bare `from` address with project_name as the display-name; an explicit display name in `from` still wins (operators keep exact control for deliverability) - `serve` reads project_name from config and passes it to from_config - `init` seeds [schema_forge] project_name from the project name so the loop closes: the name you pass to `init` survives into runtime config (TOML-escaped for names with quotes/backslashes) Defaults preserve existing behavior. Tests cover config defaults/round -trip, branded body+subject, bare-vs-explicit From, and init scaffolding. --- config.toml | 7 ++ crates/schema-forge-acton/src/config.rs | 33 +++++++ crates/schema-forge-acton/src/email.rs | 51 +++++++++- .../schema-forge-acton/src/routes/invites.rs | 40 ++++++-- crates/schema-forge-cli/src/commands/init.rs | 95 +++++++++++++++++-- crates/schema-forge-cli/src/commands/serve.rs | 8 +- 6 files changed, 210 insertions(+), 24 deletions(-) diff --git a/config.toml b/config.toml index ab7ce6c..a733690 100644 --- a/config.toml +++ b/config.toml @@ -15,6 +15,13 @@ issuer = "schema-forge" per_user_rpm = 6000 per_client_rpm = 60000 +# Human-facing name of this deployment. Shown to onboarding users in +# invitation emails (body + subject) and used as the default email From +# display-name when the `from` below is a bare address. Defaults to +# "SchemaForge" when unset — set it to your application's name. +# [schema_forge] +# project_name = "Bob's Dog Scheduling" + # Outbound email (user invitations, issue #71). Disabled by default. # The SMTP password is NEVER read from this file — supply it at runtime via # the SCHEMAFORGE_SMTP_PASSWORD environment variable so the secret stays out diff --git a/crates/schema-forge-acton/src/config.rs b/crates/schema-forge-acton/src/config.rs index 2a122e6..9460085 100644 --- a/crates/schema-forge-acton/src/config.rs +++ b/crates/schema-forge-acton/src/config.rs @@ -19,6 +19,16 @@ pub struct SchemaForgeConfig { /// SchemaForge settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchemaForgeSettings { + /// Human-facing name of this deployment, used wherever an end user sees + /// the application by name rather than seeing the SchemaForge engine. + /// Today that is the invitation email — its body and subject — and the + /// default `From` display-name when [`crate::email::EmailConfig::from`] is + /// a bare address. Defaults to `"SchemaForge"` so existing deployments are + /// unchanged; a "Bob's Dog Scheduling" deployment sets this once and every + /// onboarding touchpoint reads correctly. + #[serde(default = "default_project_name")] + pub project_name: String, + /// The URL path prefix for SchemaForge routes (default: "/forge"). #[serde(default = "default_route_prefix")] pub route_prefix: String, @@ -129,9 +139,14 @@ fn default_route_prefix() -> String { "/forge".to_string() } +fn default_project_name() -> String { + "SchemaForge".to_string() +} + impl Default for SchemaForgeSettings { fn default() -> Self { Self { + project_name: default_project_name(), route_prefix: default_route_prefix(), auto_generate_cedar_policies: false, webhooks: crate::webhook::WebhookConfig::default(), @@ -160,6 +175,7 @@ mod tests { fn serde_roundtrip_preserves_all_fields() { let config = SchemaForgeConfig { schema_forge: SchemaForgeSettings { + project_name: "Bob's Dog Scheduling".to_string(), route_prefix: "/api/forge".to_string(), auto_generate_cedar_policies: true, webhooks: crate::webhook::WebhookConfig::default(), @@ -174,10 +190,27 @@ mod tests { let json = serde_json::to_string(&config).unwrap(); let back: SchemaForgeConfig = serde_json::from_str(&json).unwrap(); assert_eq!(back.schema_forge.route_prefix, "/api/forge"); + assert_eq!(back.schema_forge.project_name, "Bob's Dog Scheduling"); assert!(back.schema_forge.auto_generate_cedar_policies); assert!(back.schema_forge.authz.principal_claims.is_empty()); } + #[test] + fn project_name_defaults_to_schemaforge() { + let config: SchemaForgeConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(config.schema_forge.project_name, "SchemaForge"); + } + + #[test] + fn project_name_deserialises_from_toml() { + let toml = r#" + [schema_forge] + project_name = "Bob's Dog Scheduling" + "#; + let config: SchemaForgeConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.schema_forge.project_name, "Bob's Dog Scheduling"); + } + #[test] fn principal_claims_section_deserialises() { let toml = r#" diff --git a/crates/schema-forge-acton/src/email.rs b/crates/schema-forge-acton/src/email.rs index 3129d4b..5bd0658 100644 --- a/crates/schema-forge-acton/src/email.rs +++ b/crates/schema-forge-acton/src/email.rs @@ -155,7 +155,15 @@ impl SmtpEmailSender { /// [`EmailError::InvalidConfig`] when a required field (`host`, `from`) /// is missing or malformed — surfacing misconfiguration at startup rather /// than on the first invite. - pub fn from_config(cfg: &EmailConfig) -> Result { + /// + /// `project_name` is the deployment's display name (from + /// `[schema_forge] project_name`). When `from` is a bare address with no + /// display name, it becomes the `From` display-name so recipients see the + /// application (e.g. `Bob's Dog Scheduling `) rather than a + /// naked address. An operator who needs an exact `From` for deliverability + /// can still embed a display name in `from` directly, which is respected + /// verbatim. + pub fn from_config(cfg: &EmailConfig, project_name: &str) -> Result { if !cfg.enabled { return Err(EmailError::NotConfigured); } @@ -169,9 +177,15 @@ impl SmtpEmailSender { .as_deref() .filter(|f| !f.is_empty()) .ok_or_else(|| EmailError::InvalidConfig("from is required".to_string()))?; - let from: Mailbox = from_raw + let parsed: Mailbox = from_raw .parse() .map_err(|e| EmailError::InvalidAddress(format!("from '{from_raw}': {e}")))?; + // Brand a bare address with the project name; respect an explicit + // display name the operator set in `from`. + let from = match (parsed.name.is_none(), project_name.trim().is_empty()) { + (true, false) => Mailbox::new(Some(project_name.to_string()), parsed.email), + _ => parsed, + }; let builder = match cfg.tls { EmailTls::Implicit => AsyncSmtpTransport::::relay(host), @@ -339,7 +353,7 @@ mod tests { fn smtp_sender_refuses_when_disabled() { let cfg = EmailConfig::default(); assert!(matches!( - SmtpEmailSender::from_config(&cfg), + SmtpEmailSender::from_config(&cfg, "SchemaForge"), Err(EmailError::NotConfigured) )); } @@ -351,11 +365,40 @@ mod tests { ..EmailConfig::default() }; assert!(matches!( - SmtpEmailSender::from_config(&cfg), + SmtpEmailSender::from_config(&cfg, "SchemaForge"), Err(EmailError::InvalidConfig(_)) )); } + // These build a real `AsyncSmtpTransport`, whose connection-pool `Drop` + // requires a Tokio runtime — run them as async tests so the runtime + // outlives the sender. + #[tokio::test] + async fn bare_from_address_is_branded_with_project_name() { + let cfg = EmailConfig { + enabled: true, + host: Some("mail.example.gov".to_string()), + from: Some("noreply@example.gov".to_string()), + ..EmailConfig::default() + }; + let sender = SmtpEmailSender::from_config(&cfg, "Bob's Dog Scheduling").unwrap(); + assert_eq!(sender.from.name.as_deref(), Some("Bob's Dog Scheduling")); + assert_eq!(sender.from.email.to_string(), "noreply@example.gov"); + } + + #[tokio::test] + async fn explicit_from_display_name_is_respected() { + let cfg = EmailConfig { + enabled: true, + host: Some("mail.example.gov".to_string()), + from: Some("Agency Mailer ".to_string()), + ..EmailConfig::default() + }; + // Operator's explicit display name wins over the project name. + let sender = SmtpEmailSender::from_config(&cfg, "Bob's Dog Scheduling").unwrap(); + assert_eq!(sender.from.name.as_deref(), Some("Agency Mailer")); + } + #[tokio::test] async fn in_memory_sender_records_messages() { let sender = InMemoryEmailSender::new(Some("https://app.agency.gov".to_string())); diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs index 7f880d7..0df317d 100644 --- a/crates/schema-forge-acton/src/routes/invites.rs +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -171,15 +171,22 @@ fn build_accept_url(base: Option<&str>, invite_id: &str) -> String { } } -/// Plain-text invitation email body. -fn invite_email_body(accept_url: &str) -> String { +/// Plain-text invitation email body, branded with the deployment's +/// `project_name` so onboarding users see the application they are joining +/// (e.g. "Bob's Dog Scheduling") rather than the underlying engine. +fn invite_email_body(project_name: &str, accept_url: &str) -> String { format!( - "You have been invited to join the SchemaForge workspace.\n\n\ + "You have been invited to join {project_name}.\n\n\ To accept the invitation and set your password, open:\n\n {accept_url}\n\n\ If you were not expecting this invitation you can ignore this message.\n" ) } +/// Subject line for the invitation email, branded with `project_name`. +fn invite_email_subject(project_name: &str) -> String { + format!("You've been invited to {project_name}") +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -299,10 +306,11 @@ pub async fn create_invite( // 5xx so the operator knows the invite did not reach the invitee. The row // stays `Pending`, so the invite can be re-sent once SMTP is healthy. let accept_url = build_accept_url(email_sender.public_base_url(), &invitation.jti); + let project_name = &state.config().custom.schema_forge.project_name; let message = EmailMessage { to: body.email.clone(), - subject: "You've been invited".to_string(), - body_text: invite_email_body(&accept_url), + subject: invite_email_subject(project_name), + body_text: invite_email_body(project_name, &accept_url), }; if let Err(e) = email_sender.send(message).await { audit_user( @@ -517,7 +525,27 @@ mod tests { #[test] fn invite_email_body_contains_link() { - let body = invite_email_body("https://app.agency.gov/invite/accept?invite=xyz"); + let body = invite_email_body( + "SchemaForge", + "https://app.agency.gov/invite/accept?invite=xyz", + ); assert!(body.contains("https://app.agency.gov/invite/accept?invite=xyz")); } + + #[test] + fn invite_email_is_branded_with_project_name() { + let body = invite_email_body("Bob's Dog Scheduling", "https://x/invite?invite=1"); + assert!( + body.contains("join Bob's Dog Scheduling"), + "email body must name the deployment, not the engine: {body}" + ); + assert!( + !body.contains("SchemaForge"), + "branded body must not leak the engine name: {body}" + ); + assert_eq!( + invite_email_subject("Bob's Dog Scheduling"), + "You've been invited to Bob's Dog Scheduling" + ); + } } diff --git a/crates/schema-forge-cli/src/commands/init.rs b/crates/schema-forge-cli/src/commands/init.rs index 72bc1e3..0eefa0d 100644 --- a/crates/schema-forge-cli/src/commands/init.rs +++ b/crates/schema-forge-cli/src/commands/init.rs @@ -23,8 +23,10 @@ pub async fn run( // Create directories and files based on template create_project_structure(&project_dir, template)?; - // Generate config.toml with defaults - create_config_file(&project_dir)?; + // Generate config.toml with defaults, branding it with the project name so + // user-facing flows (invitation emails) read with the application's name + // out of the box rather than the SchemaForge engine name. + create_config_file(&project_dir, &args.name)?; // Output summary match output.mode { @@ -126,12 +128,42 @@ fn create_project_structure(project_dir: &Path, template: Template) -> Result<() Ok(()) } -fn create_config_file(project_dir: &Path) -> Result<(), CliError> { +/// Escape a string for use inside a TOML basic (double-quoted) string. +/// +/// A project name is operator-supplied and becomes the directory name, so it +/// can contain characters (`"`, `\`) that would break the generated TOML if +/// interpolated raw. Escape exactly what a basic string requires. +fn toml_escape_basic(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\\' => out.push_str(r"\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str(r"\n"), + '\r' => out.push_str(r"\r"), + '\t' => out.push_str(r"\t"), + _ => out.push(ch), + } + } + out +} + +fn create_config_file(project_dir: &Path, project_name: &str) -> Result<(), CliError> { // Schema-forge uses acton-service's canonical config layout: SurrealDB // settings live under [surrealdb], PostgreSQL settings under [database]. // CLI flags (`--db-url`, `--db-ns`, `--db-name`) and `ACTON_*` env vars // override these in-place; there is no parallel schema-forge config layer. - let config_content = r#"[surrealdb] + let project_name_block = format!( + "[schema_forge]\n\ + # Human-facing name of this deployment. Shown to users in invitation\n\ + # emails and used as the default email From display-name. Edit this to\n\ + # the name users should recognize. Defaults to \"SchemaForge\" if removed.\n\ + project_name = \"{}\"\n\n", + toml_escape_basic(project_name) + ); + let config_content = format!( + "{project_name_block}{}", + r#"[surrealdb] url = "ws://localhost:8000" namespace = "schemaforge" database = "dev" @@ -201,8 +233,9 @@ database = "dev" # name = "release-pipeline" # issuer = "https://token.actions.githubusercontent.com" # subject_pattern = "https://github.com///.github/workflows/release.yml@refs/tags/v*" -"#; - write_file(&project_dir.join("config.toml"), config_content) +"# + ); + write_file(&project_dir.join("config.toml"), &config_content) } fn create_dir(path: &Path) -> Result<(), CliError> { @@ -304,7 +337,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let project = dir.path().join("test-project"); std::fs::create_dir_all(&project).unwrap(); - create_config_file(&project).unwrap(); + create_config_file(&project, "test-project").unwrap(); let content = std::fs::read_to_string(project.join("config.toml")).unwrap(); let parsed: toml::Value = toml::from_str(&content).unwrap(); // Template uses acton-service's canonical layout: SurrealDB lives @@ -321,6 +354,42 @@ mod tests { ); } + #[test] + fn create_config_file_brands_project_name() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("bobs-dogs"); + std::fs::create_dir_all(&project).unwrap(); + create_config_file(&project, "Bob's Dog Scheduling").unwrap(); + let content = std::fs::read_to_string(project.join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&content).unwrap(); + assert_eq!( + parsed + .get("schema_forge") + .and_then(|v| v.get("project_name")) + .and_then(|v| v.as_str()), + Some("Bob's Dog Scheduling"), + "scaffold must seed [schema_forge] project_name from the init name" + ); + } + + #[test] + fn create_config_file_escapes_project_name_for_toml() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("quoted"); + std::fs::create_dir_all(&project).unwrap(); + // A name with a double-quote and backslash must not break the TOML. + create_config_file(&project, r#"Ann "Q" \ Co"#).unwrap(); + let content = std::fs::read_to_string(project.join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&content).unwrap(); + assert_eq!( + parsed + .get("schema_forge") + .and_then(|v| v.get("project_name")) + .and_then(|v| v.as_str()), + Some(r#"Ann "Q" \ Co"#) + ); + } + /// The signing scaffold is shipped fully commented out — fresh /// projects parse as `mode = "off"` by virtue of the section not /// existing. This guards against accidental uncommenting (which @@ -331,11 +400,17 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let project = dir.path().join("test-project"); std::fs::create_dir_all(&project).unwrap(); - create_config_file(&project).unwrap(); + create_config_file(&project, "test-project").unwrap(); let content = std::fs::read_to_string(project.join("config.toml")).unwrap(); let parsed: toml::Value = toml::from_str(&content).unwrap(); + // [schema_forge] now exists (it carries project_name), but the + // signing subtable must stay commented so fresh projects parse as + // `mode = "off"` and `apply` works without trust anchors on day one. assert!( - parsed.get("schema_forge").is_none(), + parsed + .get("schema_forge") + .and_then(|v| v.get("signing")) + .is_none(), "scaffold must NOT activate [schema_forge.signing] by default; \ it ships commented so fresh projects start in `mode = \"off\"`", ); @@ -359,7 +434,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let project = dir.path().join("test-project"); std::fs::create_dir_all(&project).unwrap(); - create_config_file(&project).unwrap(); + create_config_file(&project, "test-project").unwrap(); let content = std::fs::read_to_string(project.join("config.toml")).unwrap(); // Pull every line in the [schema_forge.signing] commented diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 1bafa35..6b17093 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -361,13 +361,13 @@ pub async fn run( { email_cfg.password = Some(pw); } + let project_name = &svc_config.custom.schema_forge.project_name; let email_sender: Arc = if email_cfg.enabled { Arc::new( - schema_forge_acton::email::SmtpEmailSender::from_config(&email_cfg).map_err(|e| { - CliError::Config { + schema_forge_acton::email::SmtpEmailSender::from_config(&email_cfg, project_name) + .map_err(|e| CliError::Config { message: format!("invalid [schema_forge.email] config: {e}"), - } - })?, + })?, ) } else { Arc::new(schema_forge_acton::email::DisabledEmailSender::new( From 9b7136e14855c085c53f8745f66349ab39f0e99b Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 16:43:53 -0500 Subject: [PATCH 5/6] docs(invite): add invitations & onboarding reference Document the invite/accept endpoints (wire contract, auth model, status codes), the [schema_forge.email] SMTP configuration and the SCHEMAFORGE_SMTP_PASSWORD env-only secret path, the project_name branding knob, and the security properties (store-not-schema, single-use expiring links, signed-claims-authoritative, fail-closed delivery). README: list invitations under Implemented with a pointer to the new reference, and note that `init` seeds [schema_forge] project_name. --- README.md | 3 + docs/invitations-reference.md | 166 ++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/invitations-reference.md diff --git a/README.md b/README.md index 5e74e53..13f3762 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ my-platform/ └── main.rs ``` +The generated `config.toml` is seeded with `[schema_forge] project_name = "my-platform"` — the human-facing name shown to users in invitation emails and used as the default email `From` display-name. Edit it to whatever your users should recognize. + ### Define a Schema Create a file at `schemas/crm.schema`: @@ -930,6 +932,7 @@ SchemaForge is under active development. All seven crates compile and pass 1123 - Axum JSON API with dynamic CRUD routes and schema management - React site generator (`schemaforge site generate`) producing a Vite + Tailwind + shadcn app against the JSON API - Token-based authentication (PASETO) with an auth-store-backed login endpoint +- Email-based user invitation and onboarding with single-use PASETO invite links and SMTP delivery (see [`docs/invitations-reference.md`](docs/invitations-reference.md)) - Cedar authorization policy generation - Schema-level and field-level access control via `@access` and `@field_access` annotations - Record-level ownership-based access control diff --git a/docs/invitations-reference.md b/docs/invitations-reference.md new file mode 100644 index 0000000..1c9da94 --- /dev/null +++ b/docs/invitations-reference.md @@ -0,0 +1,166 @@ +# User invitations & onboarding reference + +Invite a person into a SchemaForge deployment by email, let them set their own password, and provision their account and tenant membership on acceptance. This document covers the two HTTP endpoints, the authorization model, the SMTP/email configuration (including how the password is supplied without ever touching git), and the `project_name` branding that controls what the invitee sees. Three readers — operators wiring SMTP for a deployment, integrators calling the invite endpoints from a console or script, and security auditors tracing the trust boundaries — should jump by heading. Scope is configuration, the wire contract, and security properties. + +## At a glance + +| | | +|---|---| +| Issue an invite | `POST /api/v1/forge/auth/invites` — **authenticated** | +| Accept an invite | `POST /api/v1/forge/auth/invites/accept` — **public** | +| Token | PASETO v4.local, `purpose = "invite"`, 7-day expiry | +| Email | `[schema_forge.email]`, disabled by default; SMTP password via env only | +| Branding | `[schema_forge] project_name` | +| Audit events | `forge.invite.created`, `forge.invite.accepted`, `forge.invite.rejected`, `forge.invite.send_failed`, `forge.access.denied` | + +The account is created **on acceptance**, not at invite time — see [Why create the account at accept](#why-create-the-account-at-accept). The invitation row is the pending state; no half-provisioned, password-less account ever sits in the user table. + +## Issuing an invitation + +`POST /api/v1/forge/auth/invites` — requires a valid bearer token. + +Request: + +```json +{ + "email": "newuser@agency.gov", + "display_name": "New User", + "tenant_type": "Organization", + "tenant_id": "01H...", + "role": "member" +} +``` + +| Field | Required | Meaning | +|---|---|---| +| `email` | yes | Invitee address; becomes `User.email`, the login identifier. Structurally validated (one `@`, a dotted domain, no whitespace, ≤ 512 chars). | +| `display_name` | no | Seeded onto the future account. | +| `tenant_type` | no | Tenant **root** type the invitee joins (e.g. `"Organization"`). Tenancy is polymorphic — this is whatever schema is annotated `@tenant(root)`. | +| `tenant_id` | no | Tenant root entity id. | +| `role` | no | Role granted to the invitee — used as **both** their `User` role and their `TenantMembership` role. | + +Success — `201 Created`: + +```json +{ + "invite_id": "Hk9c...opaque...", + "email": "newuser@agency.gov", + "expires_at": "2026-06-04T18:22:11Z" +} +``` + +`invite_id` is the opaque, high-entropy reference that is emailed to the invitee. It is the PASETO's token id (`jti`); the full token is **never** placed in the email — only this reference is. + +Failure modes: + +| Status | Cause | +|---|---| +| `401` | No / invalid bearer token. | +| `403` | The caller lacks `Create` on the user schema, or the requested `role` would exceed the caller's own role rank (see [Authorization](#authorization)). Emits `forge.access.denied`. | +| `422` | `email` failed validation, or an account already exists for that address. | +| `5xx` | The invite row was written but **email delivery failed**. The row stays `Pending` and can be re-sent once SMTP is healthy; emits `forge.invite.send_failed`. | + +The order is deliberate: the invitation is persisted **before** the email is sent, so the accept link always resolves to a row. Delivery failure is surfaced (fail-closed) rather than swallowed. + +## Accepting an invitation + +`POST /api/v1/forge/auth/invites/accept` — **public** (no bearer token; the invite token *is* the credential). + +Request: + +```json +{ + "invite_id": "Hk9c...opaque...", + "password": "the-invitee-chosen-password", + "display_name": "Optional Override" +} +``` + +`password` is validated against the same policy as `POST /users`. `display_name` falls back to the invite's value, then to the email. + +Success — `201 Created`: + +```json +{ + "email": "newuser@agency.gov", + "roles": ["member"] +} +``` + +What happens, in order: + +1. Look the row up by `invite_id`. If it is not `Pending` or has expired → `422`, emits `forge.invite.rejected`. +2. Reconstruct the full PASETO from the stored `token` column and re-verify it cryptographically. **The signed claims are authoritative** over the database columns (defense-in-depth against DB tampering): role and tenant are read from the verified token, not the row. +3. Refuse if an account now exists for the address (`409 Conflict`, `user_exists`). +4. Create the `User`, then add the `TenantMembership` (if a tenant was scoped), then mark the invitation `Consumed` — **last**, so a partial failure leaves the link retryable. + +A replayed link (already `Consumed`) is rejected with `422`. + +## Authorization + +The privilege checks run **at invite time**, against the proposed role, so deferring account creation to accept does not weaken authorization. An invite can never confer access the inviter could not grant directly. Three guards, mirroring `POST /users` exactly: + +1. **Schema access** — `check_schema_access(Create)` on the user schema. +2. **Role-grant guard** — `caller_can_grant_roles`: only a `platform_admin` may grant `platform_admin`. +3. **Cedar rank guard** — `authorize(Create, User, )` against `role_ranks.toml`; inviting a role above the caller's own rank is denied. + +## Email configuration + +`[schema_forge.email]` — **disabled by default**. When disabled, the invite endpoints fail closed with a clear "email not configured" error rather than silently dropping mail. + +```toml +[schema_forge.email] +enabled = true +host = "mail.agency.gov" +port = 465 # 465 = implicit TLS (default); 587 = STARTTLS +tls = "implicit" # or "start_tls" +from = "noreply@agency.gov" +username = "noreply@agency.gov" +public_base_url = "https://app.agency.gov" +``` + +| Field | Default | Notes | +|---|---|---| +| `enabled` | `false` | Master switch. | +| `host` | — | SMTP relay hostname. Required when enabled. | +| `port` | `465` | | +| `tls` | `implicit` | `implicit` (SMTPS, port 465) or `start_tls` (port 587). | +| `from` | — | `From` mailbox. Required when enabled. A bare address is branded with `project_name` (see below); embed a display name here — `"Agency "` — to override. | +| `username` | — | SMTP AUTH user. Omit for an unauthenticated relay. | +| `password` | — | **Never** written here — see below. | +| `public_base_url` | — | Used to build the absolute accept link, `{public_base_url}/invite/accept?invite={invite_id}`. Without it the link is site-relative. | + +TLS uses the workspace `aws-lc-rs` rustls provider (FIPS-aligned, no `ring`). + +### SMTP password — environment only + +The SMTP password is **never** read from `config.toml` and must never be committed. Supply it at runtime through the `SCHEMAFORGE_SMTP_PASSWORD` environment variable: + +```sh +SCHEMAFORGE_SMTP_PASSWORD="$(rbw get 'smtp-relay')" schemaforge serve +``` + +acton-service's `ACTON_`-prefixed Figment env layering cannot address the `[schema_forge]` section (its `Env::split("_")` shatters the underscore in the section key), so this dedicated variable — matching the existing `SCHEMAFORGE_*` convention used for the token key and trust policy — is the supported path. `serve` reads it and fills `EmailConfig.password` before constructing the transport. + +## Branding: `project_name` + +```toml +[schema_forge] +project_name = "Bob's Dog Scheduling" +``` + +`project_name` (default `"SchemaForge"`) is the human-facing name of the deployment. An onboarding user should see the application they are joining, not the engine. It drives: + +- **The invitation email body** — "You have been invited to join *Bob's Dog Scheduling*." +- **The invitation email subject** — "You've been invited to *Bob's Dog Scheduling*". +- **The `From` display-name**, when `from` is a bare address — recipients see `Bob's Dog Scheduling `. An explicit display name in `from` is respected verbatim, so operators keep exact control for deliverability. + +`schemaforge init ` seeds `project_name` from the project name into the generated `config.toml`, so the name carries from scaffold into runtime. Edit it any time. + +## Security properties + +- **Store, not schema.** Invitations live in an internal `ForgeInvitation` table that is provisioned at boot but **never inserted into the `SchemaRegistry`**. Because `/schemas` and every entity route resolve through that registry, the table — and the token material it holds — is unreachable through the public entity API. +- **Single-use, expiring.** 7-day TTL on both the PASETO `exp` and the stored `expires_at`; consumption flips the status so a replayed link is rejected. +- **Signed claims authoritative.** On accept, role and tenant come from the cryptographically verified token, not from mutable DB columns. +- **Fail-closed delivery.** A send failure is a `5xx` with the invite left `Pending`; it never reports success on undelivered mail. +- **No secret on disk.** The SMTP password enters only through the environment. From 54352a45220f209b0708260bd25b6a4196a3460e Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 16:48:15 -0500 Subject: [PATCH 6/6] chore(release): bump cli 0.33.0, acton 0.32.0, backend 0.13.0 --- Cargo.lock | 6 +++--- crates/schema-forge-acton/Cargo.toml | 2 +- crates/schema-forge-backend/Cargo.toml | 2 +- crates/schema-forge-cli/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c3b060..36f17f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7206,7 +7206,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.31.0" +version = "0.32.0" dependencies = [ "acton-service", "arc-swap", @@ -7259,7 +7259,7 @@ dependencies = [ [[package]] name = "schema-forge-backend" -version = "0.12.0" +version = "0.13.0" dependencies = [ "acton-service", "argon2", @@ -7274,7 +7274,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.32.0" +version = "0.33.0" dependencies = [ "acton-service", "assert_cmd", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index 5d60463..5f5a5c1 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.31.0" +version = "0.32.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index a288ba8..dc44344 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-backend" -version = "0.12.0" +version = "0.13.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index d7837b4..35064ff 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.32.0" +version = "0.33.0" edition = "2021" [[bin]]