diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 4706c0bae..5ba53348e 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -170,6 +170,11 @@ macro_rules! impl_openapi_schema { impl_openapi_schema!( AuthBootstrapUserRequest, AuthPublicConfig, + HostingSettings, + HostingAccess, + ProvisionalAccountRequest, + ActivationCodeResponse, + ActivationOptionsRequest, AuthUserResponse, DeviceLoginApproveRequest, DeviceLoginApproveResponse, @@ -515,6 +520,60 @@ pub trait ServerApi { #[get("/api/auth/config", status = 200, error_status = 400)] async fn auth_config(&self) -> Result; + #[get("/api/auth/registration-policy", status = 200, error_status = 400)] + async fn auth_registration_policy(&self) -> Result; + + #[get("/api/hosting/access", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_access( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[get("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_settings( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[put("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_settings_update( + &self, + #[extension] context: ServerRequestContext, + #[body] request: HostingSettings, + ) -> Result; + + #[post("/api/hosting/accounts", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_account_create( + &self, + #[extension] context: ServerRequestContext, + #[body] request: ProvisionalAccountRequest, + ) -> Result; + + #[post("/api/hosting/accounts/activation-code", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_activation_code_reissue( + &self, + #[extension] context: ServerRequestContext, + #[body] request: ProvisionalAccountRequest, + ) -> Result; + + #[post("/api/auth/activation/options", status = 200, error_status = 400, additional_error_statuses = [500])] + async fn auth_activation_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: ActivationOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/activation/complete", + responses = [(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_activation_complete( + &self, + #[body] request: PasskeyRegistrationCompleteRequest, + ) -> Result; + #[post( "/api/auth/bootstrap-user", status = 200, @@ -3846,6 +3905,76 @@ pub struct AuthPublicConfig { pub cookie_name: String, } +/// The only public registration policy. No administrator identities or host secrets are exposed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct HostingSettings { + pub self_registration_enabled: bool, +} + +/// Hosting authority for the current authenticated human, not Workspace permissions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct HostingAccess { + pub is_admin: bool, +} + +/// A hosting administrator reserves the same normalized ID used by passkey login. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ProvisionalAccountRequest { + pub handle: String, +} + +/// The only response allowed to disclose an activation code. Clients must keep it +/// out of URLs, persistent browser storage and diagnostics. A lost response is +/// recovered by reissuing, which invalidates the previous code and its ceremonies. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ActivationCodeResponse { + pub user: AuthenticatedUser, + pub code: String, + pub expires_at: String, +} + +impl std::fmt::Debug for ActivationCodeResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivationCodeResponse") + .field("user", &self.user) + .field("code", &"[REDACTED]") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// Proof for a pending account's initial passkey ceremony, never a bearer token +/// or browser session. No caller-selected account ID or enrollment purpose is accepted. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ActivationOptionsRequest { + pub handle: String, + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_origin: Option, +} + +impl std::fmt::Debug for ActivationOptionsRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivationOptionsRequest") + .field("handle", &self.handle) + .field("code", &"[REDACTED]") + .field("browser_origin", &self.browser_origin) + .finish() + } +} + /// Authentication method that established the current request actor. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -11395,6 +11524,11 @@ pub fn auth_api_typescript() -> String { let config = ts_rs::Config::default(); let declarations = [ AuthPublicConfig::decl(&config), + HostingSettings::decl(&config), + HostingAccess::decl(&config), + ProvisionalAccountRequest::decl(&config), + ActivationCodeResponse::decl(&config), + ActivationOptionsRequest::decl(&config), ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), @@ -14380,6 +14514,9 @@ mod openapi_artifact_tests { const PUBLIC_UNAUTHENTICATED: &[&str] = &[ "health", "auth_config", + "auth_registration_policy", + "auth_activation_options", + "auth_activation_complete", "auth_bootstrap_user", "auth_passkey_registration_options", "auth_passkey_registration_complete", @@ -14802,3 +14939,47 @@ mod workdir_error_diagnostic_tests { assert!(schema["properties"].get("denial_reason").is_none()); } } + +#[cfg(test)] +mod activation_contract_tests { + use super::*; + + #[test] + fn activation_inputs_reject_caller_selected_authority_and_debug_redacts_codes() { + for field in [ + "account_id", + "user_id", + "purpose", + "is_admin", + "authentication", + ] { + let mut body = serde_json::json!({"handle":"new-user","code":"activation-secret"}); + body[field] = serde_json::json!("caller-controlled"); + assert!(serde_json::from_value::(body).is_err()); + let mut body = serde_json::json!({"handle":"new-user"}); + body[field] = serde_json::json!("caller-controlled"); + assert!(serde_json::from_value::(body).is_err()); + } + let request = ActivationOptionsRequest { + handle: "new-user".into(), + code: "activation-secret".into(), + browser_origin: None, + }; + assert!(!format!("{request:?}").contains(&request.code)); + let response = ActivationCodeResponse { + user: AuthenticatedUser { + user_id: "u".into(), + account_id: "a".into(), + handle: "new-user".into(), + display_name: "new-user".into(), + }, + code: "activation-secret".into(), + expires_at: "2030-01-01T00:00:00Z".into(), + }; + assert!(!format!("{response:?}").contains(&response.code)); + assert_eq!( + serde_json::to_value(&response).unwrap()["code"], + "activation-secret" + ); + } +} diff --git a/crates/workspace-server/src/activation.rs b/crates/workspace-server/src/activation.rs new file mode 100644 index 000000000..6c7227f8a --- /dev/null +++ b/crates/workspace-server/src/activation.rs @@ -0,0 +1,203 @@ +//! Activation codes authorize only a first passkey enrollment, never a normal actor. +//! +//! Options do not consume the code: cancellation/reload may resume with ID and code. +//! Each reissue replaces the issuance identity, invalidating every previous ceremony. +//! Credential insertion and removal of the pending row share the store's write transaction. +//! Only hashes are persisted; losing the one issuance response requires explicit reissue. + +use super::*; +use ring::rand::{SecureRandom, SystemRandom}; + +/// Plaintext is returned only to the issuing administrator, never through user records. +#[derive(Clone)] +pub struct ActivationCodeIssue { + pub user: UserRecord, + pub code: String, + pub expires_at: String, +} + +impl std::fmt::Debug for ActivationCodeIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActivationCodeIssue") + .field("user", &self.user) + .field("code", &"[REDACTED]") + .field("expires_at", &self.expires_at) + .finish() + } +} + +fn denied() -> Error { + crate::auth::auth_error( + "activation_code_invalid", + "activation code is invalid, expired or locked", + ) +} + +pub(super) fn pending(conn: &Connection, user_id: &str) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM user_activations WHERE user_id=?1)", + [user_id], + |r| r.get(0), + )?) +} + +pub(super) fn require_active(conn: &Connection, user_id: &str) -> Result<()> { + if pending(conn, user_id)? { + return Err(crate::auth::auth_error( + "activation_required", + "a first passkey must be registered before account use", + )); + } + Ok(()) +} + +pub(super) fn require_active_account(conn: &Connection, account_id: &str) -> Result<()> { + let pending: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM users u JOIN user_activations p ON p.user_id=u.user_id WHERE u.account_id=?1)", [account_id], |r| r.get(0))?; + if pending { + return Err(crate::auth::auth_error( + "activation_required", + "a pending account cannot own a Workspace", + )); + } + Ok(()) +} + +fn require_admin(tx: &Connection, actor: &server_api::RequestActor) -> Result<()> { + let allowed: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id=h.user_id JOIN accounts a ON a.account_id=u.account_id WHERE u.user_id=?1 AND u.account_id=?2 AND a.kind='user' AND NOT EXISTS(SELECT 1 FROM user_activations p WHERE p.user_id=u.user_id))", + params![actor.user_id, actor.account_id], |r| r.get(0), + )?; + if !allowed { + return Err(Error::WorkspacePermissionDenied( + "hosting_admin_required: a live hosting administrator is required".into(), + )); + } + Ok(()) +} + +fn issue(tx: &Connection, user: UserRecord) -> Result { + let mut bytes = [0u8; 32]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| Error::Store("activation code entropy unavailable".into()))?; + let code: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let expires_at = crate::auth::rfc3339_after(chrono::Duration::minutes(15)); + let issuance_id = crate::auth::new_id("activation"); + tx.execute("INSERT INTO user_activations(user_id,issuance_id,code_hash,expires_at,failed_attempts) VALUES(?1,?2,?3,?4,0) ON CONFLICT(user_id) DO UPDATE SET issuance_id=excluded.issuance_id,code_hash=excluded.code_hash,expires_at=excluded.expires_at,failed_attempts=0", + params![user.user_id, issuance_id, crate::auth::token_hash(&code), expires_at])?; + Ok(ActivationCodeIssue { + user, + code, + expires_at, + }) +} + +pub(super) fn create( + store: &SqliteWorkspaceStore, + actor: &server_api::RequestActor, + handle: &str, +) -> Result { + let handle = crate::auth::normalize_handle(handle)?; + store.with_immediate_transaction(|tx| { + require_admin(tx, actor)?; + let occupied: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM users WHERE handle=?1) OR EXISTS(SELECT 1 FROM accounts WHERE kind='user' AND handle=?1)", [&handle], |r| r.get(0))?; + if occupied { return Err(crate::auth::auth_error("auth_handle_exists", "handle is already registered")); } + let now = crate::auth::now_rfc3339(); + let user = UserRecord { user_id: crate::auth::new_id("user"), account_id: crate::auth::new_id("acct-user"), handle: handle.clone(), display_name: handle.clone(), created_at: now.clone(), updated_at: now.clone() }; + tx.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?2,?3,?3)", params![user.account_id, handle, now])?; + tx.execute("INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?3,?4,?4)", params![user.user_id, user.account_id, handle, now])?; + issue(tx, user) + }) +} + +pub(super) fn reissue( + store: &SqliteWorkspaceStore, + actor: &server_api::RequestActor, + handle: &str, +) -> Result { + let handle = crate::auth::normalize_handle(handle)?; + store.with_immediate_transaction(|tx| { + require_admin(tx, actor)?; + let user = tx + .query_row( + user_select_sql("WHERE handle=?1").as_str(), + [&handle], + read_user_record, + ) + .optional()? + .ok_or_else(denied)?; + if !pending(tx, &user.user_id)? { + return Err(denied()); + } + require_pending_target(tx, &user, None)?; + issue(tx, user) + }) +} + +/// A server-retained target is still checked against current account and issuance authority. +/// Reissue may replace an expired/locked issuance, but completion never may use one. +pub(super) fn require_pending_target( + conn: &Connection, + user: &UserRecord, + issuance: Option<&str>, +) -> Result<()> { + let valid: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM users u JOIN accounts a ON a.account_id=u.account_id JOIN user_activations p ON p.user_id=u.user_id WHERE u.user_id=?1 AND u.account_id=?2 AND u.handle=?3 AND a.kind='user' AND a.handle=u.handle AND NOT EXISTS(SELECT 1 FROM passkey_credentials c WHERE c.user_id=u.user_id))", params![user.user_id,user.account_id,user.handle], |r| r.get(0))?; + if !valid { + return Err(denied()); + } + if let Some(issuance) = issuance { + let expiry: Option = conn.query_row("SELECT expires_at FROM user_activations WHERE user_id=?1 AND issuance_id=?2 AND failed_attempts<5", params![user.user_id,issuance], |r| r.get(0)).optional()?; + if !expiry.is_some_and(|expiry| !crate::auth::is_expired(&expiry)) { + return Err(denied()); + } + } + Ok(()) +} + +pub(super) fn verify( + store: &SqliteWorkspaceStore, + handle: &str, + code: &str, +) -> Result { + let handle = crate::auth::normalize_handle(handle).map_err(|_| denied())?; + // Inner result is deliberate: rejected attempts must commit before the public error. + store.with_immediate_transaction(|tx| { + let user = tx.query_row(user_select_sql("WHERE handle=?1").as_str(), [&handle], read_user_record).optional()?; + let Some(user) = user else { return Ok(Err(denied())); }; + let row: Option<(String,String,String,i64)> = tx.query_row("SELECT issuance_id,code_hash,expires_at,failed_attempts FROM user_activations WHERE user_id=?1", [&user.user_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?; + let Some((issuance_id,hash,expiry,attempts)) = row else { return Ok(Err(denied())); }; + if attempts >= 5 || crate::auth::is_expired(&expiry) { return Ok(Err(denied())); } + if crate::auth::token_hash(code) != hash { + tx.execute("UPDATE user_activations SET failed_attempts=failed_attempts+1 WHERE user_id=?1", [&user.user_id])?; + return Ok(Err(denied())); + } + require_pending_target(tx, &user, Some(&issuance_id))?; + Ok(Ok(PasskeyRegistrationTarget::Activation { user, issuance_id })) + })? +} + +pub(super) fn migrate(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 99 { + return Err(Error::Store( + "Activation migration requires schema 99".into(), + )); + } + let tx = conn.unchecked_transaction()?; + tx.execute_batch(include_str!("activation_schema.sql"))?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(100,?1)", + ["Provisional users and scoped passkey activation"], + )?; + verify_workspace_resource_constraints(&tx)?; + let violations: i64 = + tx.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| { + r.get(0) + })?; + if violations != 0 { + return Err(Error::Store( + "Activation migration foreign-key violation".into(), + )); + } + tx.commit()?; + Ok(()) +} diff --git a/crates/workspace-server/src/activation_schema.sql b/crates/workspace-server/src/activation_schema.sql new file mode 100644 index 000000000..17b3e99ef --- /dev/null +++ b/crates/workspace-server/src/activation_schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE user_activations ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + issuance_id TEXT NOT NULL UNIQUE, + code_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts BETWEEN 0 AND 5) +); diff --git a/crates/workspace-server/src/activation_tests.rs b/crates/workspace-server/src/activation_tests.rs new file mode 100644 index 000000000..faa769661 --- /dev/null +++ b/crates/workspace-server/src/activation_tests.rs @@ -0,0 +1,684 @@ +// Component tests use production store methods and real isolated SQLite files/connections. +// WebAuthn verification is outside this suite: credentials enter at the verified-store boundary. +// Included in store::tests to reuse genuine retained migration fixtures. Run with `activation_`. + +fn activation_fixture() -> (SqliteWorkspaceStore, server_api::RequestActor) { + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + let admin = store.setup_hosting_admin("admin", true).unwrap(); + let actor = hosting_actor(&admin.user); + (store, actor) +} + +#[test] +fn activation_options_resume_without_consuming_and_completion_is_one_time() { + let (store, admin) = activation_fixture(); + assert!(!store.hosting_self_registration_enabled().unwrap()); + let issue = store + .create_provisional_user(&admin, " New-Person ") + .unwrap(); + assert_eq!(issue.user.handle, "new-person"); + assert_eq!(issue.user.display_name, "new-person"); + assert!( + store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert_eq!( + store.get_user(&issue.user.user_id).unwrap(), + Some(issue.user.clone()) + ); + let target = store + .verify_activation_code("NEW-PERSON", &issue.code) + .unwrap(); + assert_eq!( + store + .verify_activation_code("new-person", &issue.code) + .unwrap(), + target + ); + assert!(store.verify_activation_code("admin", &issue.code).is_err()); + assert!( + store + .verify_activation_code("other-person", &issue.code) + .is_err() + ); + assert!( + store + .list_passkey_credentials_for_user(&issue.user.user_id) + .unwrap() + .is_empty() + ); + assert!( + store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "first-key")) + .unwrap(); + assert!( + !store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert!( + store + .verify_activation_code("new-person", &issue.code) + .is_err() + ); + assert!(store.reissue_activation_code(&admin, "new-person").is_err()); + assert!( + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "second-key")) + .is_err() + ); + assert_eq!( + store + .list_passkey_credentials_for_user(&issue.user.user_id) + .unwrap() + .len(), + 1 + ); + assert!(!store.is_hosting_admin(&issue.user.user_id).unwrap()); +} + +#[test] +fn activation_invalid_attempts_commit_lockout_across_reopen_and_reissue_resets_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let admin = hosting_actor(&store.setup_hosting_admin("admin", true).unwrap().user); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let target = store.verify_activation_code("person", &issue.code).unwrap(); + for expected in 1..=5 { + assert!(store.verify_activation_code("person", "wrong").is_err()); + store + .with_conn(|conn| { + let attempts: i64 = conn.query_row( + "SELECT failed_attempts FROM user_activations WHERE user_id=?1", + [&issue.user.user_id], + |r| r.get(0), + )?; + assert_eq!(attempts, expected); + Ok(()) + }) + .unwrap(); + } + drop(store); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert!(store.verify_activation_code("person", &issue.code).is_err()); + assert!( + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "locked-key")) + .is_err() + ); + let next = store.reissue_activation_code(&admin, "PERSON").unwrap(); + assert!(issue.code != next.code, "reissue must replace the secret"); + assert!(store.verify_activation_code("person", &issue.code).is_err()); + assert!( + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "old-key")) + .is_err() + ); + let target = store.verify_activation_code("person", &next.code).unwrap(); + store + .register_verified_passkey(&target, &hosting_credential(&next.user, "fresh-key")) + .unwrap(); +} + +#[test] +fn activation_expiry_and_reissued_ceremonies_are_rechecked_at_completion() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let old = store.verify_activation_code("person", &issue.code).unwrap(); + let next = store.reissue_activation_code(&admin, "person").unwrap(); + assert!( + store + .register_verified_passkey(&old, &hosting_credential(&issue.user, "old-key")) + .is_err() + ); + let target = store.verify_activation_code("person", &next.code).unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE user_activations SET expires_at='2000-01-01T00:00:00Z'", + [], + )?; + Ok(()) + }) + .unwrap(); + assert!(store.verify_activation_code("person", &next.code).is_err()); + assert!( + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "expired-key")) + .is_err() + ); + assert!( + store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert!( + store + .list_passkey_credentials_for_user(&issue.user.user_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn activation_credential_failure_rolls_back_pending_removal_and_allows_retry() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let target = store.verify_activation_code("person", &issue.code).unwrap(); + store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_activation_credential BEFORE INSERT ON passkey_credentials BEGIN SELECT RAISE(ABORT,'injected credential failure'); END;")?; Ok(()) }).unwrap(); + assert!( + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "key")) + .is_err() + ); + assert!( + store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert!( + store + .list_passkey_credentials_for_user(&issue.user.user_id) + .unwrap() + .is_empty() + ); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER reject_activation_credential")?; + Ok(()) + }) + .unwrap(); + let target = store.verify_activation_code("person", &issue.code).unwrap(); + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "key")) + .unwrap(); + assert!( + !store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); +} + +#[test] +fn activation_completion_checks_account_issuance_user_and_absence_of_credentials() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let original = store.verify_activation_code("person", &issue.code).unwrap(); + let PasskeyRegistrationTarget::Activation { user, issuance_id } = &original else { + panic!("wrong purpose") + }; + let mut wrong_user = user.clone(); + wrong_user.account_id = admin.account_id; + let wrong = PasskeyRegistrationTarget::Activation { + user: wrong_user, + issuance_id: issuance_id.clone(), + }; + assert!( + store + .register_verified_passkey(&wrong, &hosting_credential(user, "wrong-account")) + .is_err() + ); + let mut credential = hosting_credential(user, "wrong-user"); + credential.user_id = admin.user_id; + assert!( + store + .register_verified_passkey(&original, &credential) + .is_err() + ); + // Existing credentials, even if injected outside public admission, may not become recovery. + store.with_conn(|conn| { conn.execute("INSERT INTO passkey_credentials(credential_id,user_id,public_key_cose,sign_count,created_at) VALUES('existing',?1,'key',0,'1')", [&user.user_id])?; Ok(()) }).unwrap(); + assert!( + store + .register_verified_passkey(&original, &hosting_credential(user, "second")) + .is_err() + ); + assert!( + store + .reissue_activation_code( + &hosting_actor(&store.get_user_by_handle("admin").unwrap().unwrap()), + "person" + ) + .is_err() + ); + assert!(store.is_user_pending_activation(&user.user_id).unwrap()); +} + +#[test] +fn activation_admin_authority_is_live_and_duplicate_handles_preserve_accounts() { + let (store, admin) = activation_fixture(); + let existing = store.setup_hosting_admin("existing", true).unwrap().user; + let normal = hosting_actor(&existing); + store + .with_conn(|conn| { + conn.execute( + "DELETE FROM hosting_administrators WHERE user_id=?1", + [&existing.user_id], + )?; + Ok(()) + }) + .unwrap(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + assert!(store.create_provisional_user(&normal, "forbidden").is_err()); + assert!(store.reissue_activation_code(&normal, "person").is_err()); + let mut fake = admin.clone(); + fake.account_id = existing.account_id.clone(); + assert!(store.create_provisional_user(&fake, "fake").is_err()); + assert!(store.reissue_activation_code(&fake, "person").is_err()); + for handle in ["existing", "EXISTING", "person"] { + assert!(store.create_provisional_user(&admin, handle).is_err()); + } + assert_eq!(store.get_user(&existing.user_id).unwrap(), Some(existing)); + assert!(store.reissue_activation_code(&admin, "existing").is_err()); + store + .with_conn(|conn| { + conn.execute( + "DELETE FROM hosting_administrators WHERE user_id=?1", + [&admin.user_id], + )?; + Ok(()) + }) + .unwrap(); + assert!(store.create_provisional_user(&admin, "revoked").is_err()); + assert!(store.reissue_activation_code(&admin, "person").is_err()); + assert_eq!( + store.verify_activation_code("person", &issue.code).unwrap(), + store.verify_activation_code("person", &issue.code).unwrap() + ); +} + +#[test] +fn activation_pending_cannot_mint_or_resolve_normal_auth_or_gain_hosting_setup() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let session = BrowserSessionRecord { + session_id: "session".into(), + token_hash: "session-hash".into(), + user_id: issue.user.user_id.clone(), + created_at: "1".into(), + expires_at: crate::auth::rfc3339_after(chrono::Duration::hours(1)), + revoked_at: None, + }; + let token = ApiTokenRecord { + token_id: "token".into(), + token_hash: "token-hash".into(), + user_id: issue.user.user_id.clone(), + label: "test".into(), + created_at: "1".into(), + expires_at: None, + revoked_at: None, + last_used_at: None, + }; + assert!(store.create_browser_session(&session).is_err()); + assert!(store.create_api_token(&token).is_err()); + assert!( + store + .upsert_passkey_credential(&hosting_credential(&issue.user, "bypass")) + .is_err() + ); + assert!(store.setup_hosting_admin("person", false).is_err()); + // Corrupt/legacy credential rows cannot bypass resolution or authorize Addition. + store.with_conn(|conn| { + conn.execute("INSERT INTO browser_sessions(session_id,token_hash,user_id,created_at,expires_at) VALUES(?1,?2,?3,?4,?5)", params![session.session_id,session.token_hash,session.user_id,session.created_at,session.expires_at])?; + conn.execute("INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at) VALUES(?1,?2,?3,?4,?5)", params![token.token_id,token.token_hash,token.user_id,token.label,token.created_at])?; + Ok(()) + }).unwrap(); + assert!( + store + .resolve_browser_session(&session.token_hash) + .unwrap() + .is_none() + ); + assert!( + store + .resolve_api_token(&token.token_hash) + .unwrap() + .is_none() + ); + assert!( + store + .register_verified_passkey( + &PasskeyRegistrationTarget::Addition { + user: issue.user.clone(), + authentication: PasskeyAuthenticationBinding::ApiToken( + token.token_hash.clone() + ) + }, + &hosting_credential(&issue.user, "addition") + ) + .is_err() + ); + assert!( + store + .approve_device_login_flow( + "unknown", + &issue.user.user_id, + &token.token_id, + "secret", + "1" + ) + .is_err() + ); + store + .with_conn(|conn| { + assert!(activation::require_active_account(conn, &issue.user.account_id).is_err()); + Ok(()) + }) + .unwrap(); + let target = store.verify_activation_code("person", &issue.code).unwrap(); + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "key")) + .unwrap(); + store + .create_browser_session(&BrowserSessionRecord { + session_id: "active".into(), + token_hash: "active-hash".into(), + ..session + }) + .unwrap(); + store + .create_api_token(&ApiTokenRecord { + token_id: "active".into(), + token_hash: "active-hash".into(), + ..token + }) + .unwrap(); +} + +#[test] +fn activation_plaintext_never_persists_and_pending_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let admin = hosting_actor(&store.setup_hosting_admin("admin", true).unwrap().user); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + assert!(!format!("{issue:?}").contains(&issue.code)); + let expiry = chrono::DateTime::parse_from_rfc3339(&issue.expires_at).unwrap(); + let seconds = (expiry.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds(); + assert!((890..=900).contains(&seconds)); + store + .with_conn(|conn| { + let hash: String = conn.query_row( + "SELECT code_hash FROM user_activations WHERE user_id=?1", + [&issue.user.user_id], + |r| r.get(0), + )?; + assert_eq!(hash, crate::auth::token_hash(&issue.code)); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?; + Ok(()) + }) + .unwrap(); + drop(store); + for entry in std::fs::read_dir(dir.path()).unwrap() { + let bytes = std::fs::read(entry.unwrap().path()).unwrap(); + assert!( + !bytes + .windows(issue.code.len()) + .any(|window| window == issue.code.as_bytes()) + ); + } + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert!( + reopened + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert!( + reopened + .verify_activation_code("person", &issue.code) + .is_ok() + ); +} + +#[test] +fn activation_parallel_options_complete_once_and_parallel_duplicate_create_has_one_winner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + let first = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let second = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let admin = hosting_actor(&first.setup_hosting_admin("admin", true).unwrap().user); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let jobs = [first.clone(), second.clone()] + .into_iter() + .map(|store| { + let barrier = barrier.clone(); + let admin = admin.clone(); + std::thread::spawn(move || { + barrier.wait(); + store.create_provisional_user(&admin, "person") + }) + }) + .collect::>(); + let results = jobs + .into_iter() + .map(|job| job.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + let issue = results.into_iter().find_map(|result| result.ok()).unwrap(); + let targets = [ + first.verify_activation_code("person", &issue.code).unwrap(), + second + .verify_activation_code("person", &issue.code) + .unwrap(), + ]; + let jobs = [first.clone(), second] + .into_iter() + .zip(targets) + .enumerate() + .map(|(index, (store, target))| { + let barrier = barrier.clone(); + let credential = hosting_credential(&issue.user, &format!("key-{index}")); + std::thread::spawn(move || { + barrier.wait(); + store.register_verified_passkey(&target, &credential) + }) + }) + .collect::>(); + let results = jobs + .into_iter() + .map(|job| job.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert!( + !first + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert_eq!( + first + .list_passkey_credentials_for_user(&issue.user.user_id) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn activation_oldest_and_previous_schema_migrate_with_same_constraints_and_preserve_users() { + fn schema(conn: &Connection) -> String { + conn.query_row( + "SELECT sql FROM sqlite_schema WHERE name='user_activations'", + [], + |r| r.get(0), + ) + .unwrap() + } + let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + let expected = fresh.with_conn(|conn| Ok(schema(conn))).unwrap(); + for baseline in [50, 99] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + if baseline == 50 { + prepare_schema_v50(&path, None); + } else { + prepare_retained_schema(&path, baseline); + } + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("INSERT OR IGNORE INTO accounts VALUES('old-account','user','old','Old','1','1'); INSERT INTO users VALUES('old-user','old-account','old','Old','1','1');").unwrap(); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert_eq!( + store.get_user("old-user").unwrap().unwrap().display_name, + "Old" + ); + assert!(!store.is_user_pending_activation("old-user").unwrap()); + store.with_conn(|conn| { + assert_eq!(schema(conn), expected); + assert_eq!(current_schema_version(conn)?, 100); + assert!(conn.execute("INSERT INTO user_activations VALUES('missing','issue','hash','future',0)", []).is_err()); + for attempts in [-1, 6] { assert!(conn.execute("INSERT INTO user_activations VALUES('old-user','issue','hash','future',?1)", [attempts]).is_err()); } + Ok(()) + }).unwrap(); + } +} + +#[test] +fn activation_migration_failure_rolls_back_table_and_history() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + prepare_retained_schema(&path, 99); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch("CREATE TRIGGER reject_activation_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=100 BEGIN SELECT RAISE(ABORT,'injected migration failure'); END;").unwrap(); + assert!(activation::migrate(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 99); + assert!(!table_exists(&conn, "user_activations").unwrap()); + conn.execute_batch("DROP TRIGGER reject_activation_migration") + .unwrap(); + activation::migrate(&conn).unwrap(); + assert!(table_exists(&conn, "user_activations").unwrap()); + assert_eq!(current_schema_version(&conn).unwrap(), 100); +} + +#[tokio::test] +async fn activation_pending_workspace_owner_is_rejected_by_both_store_creation_paths() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let workspace = WorkspaceRecord { + workspace_id: "pending-workspace".into(), + owner_account_id: issue.user.account_id.clone(), + display_name: "Pending".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }; + assert!( + store + .upsert_workspace(&workspace) + .await + .unwrap_err() + .to_string() + .contains("activation_required") + ); + let signing = WorkspaceSigningIdentityActivation { + workspace_id: workspace.workspace_id.clone(), + key_id: "key".into(), + public_key: "public".into(), + public_key_fingerprint: "sha256:key".into(), + private_material_ref: "private-ref".into(), + provisioned_at: "1".into(), + }; + let record = WorkspaceBootstrapRecord { + operation_key: "operation".into(), + request_fingerprint: "sha256:request".into(), + workspace: workspace.clone(), + repository: None, + }; + assert!( + store + .create_workspace_bootstrap(&record, &signing, "provision") + .unwrap_err() + .to_string() + .contains("activation_required") + ); + assert!( + store + .get_workspace(&workspace.workspace_id) + .await + .unwrap() + .is_none() + ); + let target = store.verify_activation_code("person", &issue.code).unwrap(); + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "key")) + .unwrap(); + store.upsert_workspace(&workspace).await.unwrap(); + assert_eq!( + store.get_workspace(&workspace.workspace_id).await.unwrap(), + Some(workspace) + ); +} + +#[test] +fn activation_pending_removal_failure_rolls_back_inserted_credential() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let target = store.verify_activation_code("person", &issue.code).unwrap(); + store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_pending_removal BEFORE DELETE ON user_activations BEGIN SELECT RAISE(ABORT,'injected activation failure'); END;")?; Ok(()) }).unwrap(); + assert!( + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "key")) + .is_err() + ); + assert!( + store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); + assert!(store.get_passkey_credential("key").unwrap().is_none()); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER reject_pending_removal")?; + Ok(()) + }) + .unwrap(); + store + .register_verified_passkey(&target, &hosting_credential(&issue.user, "key")) + .unwrap(); + assert!( + !store + .is_user_pending_activation(&issue.user.user_id) + .unwrap() + ); +} + +#[test] +fn activation_pending_device_token_is_not_delivered_even_with_injected_approval() { + let (store, admin) = activation_fixture(); + let issue = store.create_provisional_user(&admin, "person").unwrap(); + let flow = DeviceLoginFlowRecord { + device_code: "device".into(), + user_code: "CODE".into(), + verification_uri: "/device".into(), + client_name: None, + user_id: Some(issue.user.user_id.clone()), + api_token_id: None, + issued_access_token: Some("not-deliverable".into()), + created_at: "1".into(), + expires_at: crate::auth::rfc3339_after(chrono::Duration::minutes(5)), + approved_at: Some("1".into()), + consumed_at: None, + }; + assert!(store.try_create_device_login_flow(&flow).is_err()); + store.with_conn(|conn| { conn.execute("INSERT INTO device_login_flows(device_code,user_code,verification_uri,user_id,issued_access_token,created_at,expires_at,approved_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![flow.device_code,flow.user_code,flow.verification_uri,flow.user_id,flow.issued_access_token,flow.created_at,flow.expires_at,flow.approved_at])?; Ok(()) }).unwrap(); + assert!( + store + .consume_device_login_token("device", "2") + .unwrap() + .is_none() + ); + assert!( + store + .get_device_login_flow_by_device_code("device") + .unwrap() + .unwrap() + .consumed_at + .is_none() + ); + assert!(!store.is_hosting_admin(&issue.user.user_id).unwrap()); +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 15ce6f520..81b3ceab1 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -472,7 +472,9 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip ), (96,"Backend Job common notification routing".into()), (97, "MR notifications and authenticated implementation results".into()), - (98, "Assignment-independent implementation reports".into()) + (98, "Assignment-independent implementation reports".into()), + (99, "Explicit hosting administrators and self-registration policy".into()), + (100, "Provisional users and scoped passkey activation".into()) ] ); for (table, rows) in retained.iter().zip(original.iter()) { diff --git a/crates/workspace-server/src/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index f362c80a8..33660ecb1 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -54,7 +54,7 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never let before = store.with_conn(|conn| { // Retention holds are not reporting permissions. conn.execute("UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w' AND worker_id='replacement'", [])?; - assert_eq!(current_schema_version(conn)?, 98); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(conn.query_row("SELECT count(*) FROM ticket_worker_assignments", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0); @@ -372,7 +372,7 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no let (dir, store) = tracking_store_fixture().await; tracking_store_assign(&store, "delegation", "assign"); let worker = routing_worker_assign(&store, "implementation").worker; - // Reconstruct schema97 from a fresh schema98 fixture, including its historical capture. + // Reconstruct schema97 from a fresh current-schema fixture, including its historical capture. store.with_conn(|conn| { conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; @@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; + DROP TABLE user_activations; + UPDATE __yoi_schema_migrations SET version=99 WHERE version=100; + DROP TABLE hosting_administrators; DROP TABLE hosting_settings; + UPDATE __yoi_schema_migrations SET version=98 WHERE version=99; UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; migrate_business_notifications_v96_to_v97(conn)?; assert_eq!(current_schema_version(conn)?, 97); @@ -509,6 +513,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow // Reconstruct the prior composition before its new business capture existed. conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; + DROP TABLE user_activations; + UPDATE __yoi_schema_migrations SET version=99 WHERE version=100; + DROP TABLE hosting_administrators; DROP TABLE hosting_settings; + UPDATE __yoi_schema_migrations SET version=98 WHERE version=99; UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3'); INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3'); @@ -663,7 +671,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() { if historical { conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; - UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; + DROP TABLE user_activations; + UPDATE __yoi_schema_migrations SET version=99 WHERE version=100; + DROP TABLE hosting_administrators; DROP TABLE hosting_settings; + UPDATE __yoi_schema_migrations SET version=98 WHERE version=99; + UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; } conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3'); INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?; diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index be5790c66..890395f91 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -12,6 +12,15 @@ DROP TABLE typed_ticket_events; DROP TABLE typed_tickets; DROP TABLE workspace_resource_key_counters; DROP TABLE workspace_resource_keys; +CREATE TABLE hosting_administrators ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); +CREATE TABLE hosting_settings ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)) +); +INSERT INTO hosting_settings(singleton, self_registration_enabled) VALUES(1, 0); CREATE TABLE accounts ( account_id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index f68f9f639..512332527 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -22,6 +22,7 @@ enum Command { Serve(ServeOptions), Migrate(MigrateOptions), BootstrapAuth(BootstrapAuthOptions), + HostingAdmin(HostingAdminOptions), Skills(SkillsCommand), Help, } @@ -46,6 +47,13 @@ struct BootstrapAuthOptions { label: Option, } +#[derive(Debug)] +struct HostingAdminOptions { + handle: String, + create_user: bool, + database: Option, +} + #[derive(Debug)] struct SkillWorkspaceOptions { workspace_id: String, @@ -86,6 +94,7 @@ async fn run() -> Result<(), Box> { Command::Serve(options) => run_serve(options).await, Command::Migrate(options) => run_migrate(options), Command::BootstrapAuth(options) => run_bootstrap_auth(options), + Command::HostingAdmin(options) => run_hosting_admin(options), Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -100,6 +109,13 @@ fn parse_command(args: &[String]) -> Result { match command.as_str() { "migrate" => parse_migrate_options(rest).map(Command::Migrate), "bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth), + "hosting-admin" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + print_hosting_admin_help(); + return Ok(Command::Help); + } + parse_hosting_admin_options(rest).map(Command::HostingAdmin) + } "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -113,7 +129,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`" ))), } } @@ -224,6 +240,85 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box Ok(()) } +fn run_hosting_admin(options: HostingAdminOptions) -> Result<(), Box> { + let database_path = options + .database + .unwrap_or_else(ServerConfig::default_server_database_path); + if let Some(parent) = database_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + let store = SqliteWorkspaceStore::open(&database_path)?; + let setup = store.setup_hosting_admin(&options.handle, options.create_user)?; + let mut output = serde_json::json!({ + "user": { + "user_id": setup.user.user_id, + "account_id": setup.user.account_id, + "handle": setup.user.handle, + "display_name": setup.user.display_name, + }, + "hosting_admin": true, + }); + if let Some(bearer) = setup.initial_bearer { + output["access_token"] = bearer.access_token.into(); + output["expires_at"] = bearer.expires_at.into(); + output["token_type"] = "bearer".into(); + } + // Explicit trusted host setup is the only place this secret is printed. + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} + +fn parse_hosting_admin_options(args: &[String]) -> Result { + let mut handle = None; + let mut database = None; + let mut create_user = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--create-user" { + if create_user { + return Err(CliError("--create-user must be provided only once".into())); + } + create_user = true; + index += 1; + continue; + } + let (name, inline) = arg + .split_once('=') + .map_or((arg.as_str(), None), |(name, value)| (name, Some(value))); + let target = match name { + "--handle" => &mut handle, + "--database" => &mut database, + other => return Err(CliError(format!("unknown hosting-admin option {other}"))), + }; + let value = match inline { + Some(value) => value.to_string(), + None => { + index += 1; + args.get(index) + .filter(|value| !value.starts_with("--")) + .ok_or_else(|| CliError(format!("{name} requires a value")))? + .clone() + } + }; + if value.is_empty() { + return Err(CliError(format!("{name} requires a value"))); + } + if target.replace(value).is_some() { + return Err(CliError(format!("{name} must be provided only once"))); + } + index += 1; + } + Ok(HostingAdminOptions { + handle: handle.ok_or_else(|| CliError("hosting-admin requires --handle".into()))?, + create_user, + database: database.map(PathBuf::from), + }) +} + fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box> { let database_path = ServerConfig::default_server_database_path(); if let Some(parent) = database_path.parent() { @@ -643,7 +738,13 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle [--display-name ] [--label