chore: merge develop into hare/develop

This commit is contained in:
2026-10-16 15:08:40 +09:00
103 changed files with 16034 additions and 984 deletions
+203
View File
@@ -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<bool> {
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<ActivationCodeIssue> {
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<ActivationCodeIssue> {
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<ActivationCodeIssue> {
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<String> = 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<PasskeyRegistrationTarget> {
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(())
}
@@ -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)
);
@@ -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::<Vec<_>>();
let results = jobs
.into_iter()
.map(|job| job.join().unwrap())
.collect::<Vec<_>>();
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::<Vec<_>>();
let results = jobs
.into_iter()
.map(|job| job.join().unwrap())
.collect::<Vec<_>>();
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());
}
@@ -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()) {
@@ -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');")?;
@@ -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')),
+149 -3
View File
@@ -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<String>,
}
#[derive(Debug)]
struct HostingAdminOptions {
handle: String,
create_user: bool,
database: Option<PathBuf>,
}
#[derive(Debug)]
struct SkillWorkspaceOptions {
workspace_id: String,
@@ -86,6 +94,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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<Command, CliError> {
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<Command, CliError> {
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<dyn std::error::Error>
Ok(())
}
fn run_hosting_admin(options: HostingAdminOptions) -> Result<(), Box<dyn std::error::Error>> {
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<HostingAdminOptions, CliError> {
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<dyn std::error::Error>> {
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<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_hosting_admin_help() {
println!(
"yoi-server hosting-admin\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n\nDescription:\n Trusted host-only explicit administrator setup. By default selects an existing known user and grants hosting authority without issuing credentials. --create-user requires a new handle and atomically creates the user, grant and initial bearer (15-minute expiry), printed once to private stdout for passkey addition. Open /hosting/setup on this Server's trusted HTTPS origin (or localhost), enter the token, add a passkey, then explicitly sign in with that key before opening /hosting/settings. Repeating creation fails, never refreshing credentials. Neither mode enables public self-registration. Existing Workspace permissions are unchanged.\n\nOptions:\n --handle <HANDLE> Known user handle, or new handle with --create-user\n --create-user Explicitly create a new user and print its initial bearer\n --database <PATH> Server DB path (default: canonical Yoi server DB)\n -h, --help Print help"
);
}
@@ -702,6 +803,51 @@ mod tests {
assert_eq!(error.to_string(), "bootstrap-auth requires --handle");
}
#[test]
fn hosting_admin_cli_requires_explicit_creation_and_selects_database() {
let args = |values: &[&str]| {
values
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
};
let Command::HostingAdmin(existing) =
parse_command(&args(&["hosting-admin", "--handle", "known-user"])).unwrap()
else {
panic!("expected hosting admin")
};
assert!(!existing.create_user);
assert!(existing.database.is_none());
let Command::HostingAdmin(new) = parse_command(&args(&[
"hosting-admin",
"--handle=new-admin",
"--create-user",
"--database=/tmp/isolated.db",
]))
.unwrap() else {
panic!("expected hosting admin")
};
assert!(new.create_user);
assert_eq!(new.handle, "new-admin");
assert_eq!(new.database, Some(PathBuf::from("/tmp/isolated.db")));
for invalid in [
vec![],
vec!["--create-user"],
vec!["--handle"],
vec!["--handle="],
vec!["--handle", "a", "--handle=b"],
vec!["--handle", "a", "--database"],
vec!["--handle", "a", "--database=first", "--database=second"],
vec!["--handle", "a", "--create-user", "--create-user"],
vec!["--handle", "a", "--token", "secret"],
] {
assert!(
parse_hosting_admin_options(&args(&invalid)).is_err(),
"{invalid:?}"
);
}
}
#[test]
fn parse_migrate_uses_the_shared_schema_path() {
let command = parse_command(&[
@@ -832,7 +978,7 @@ mod tests {
assert_eq!(
error.to_string(),
format!(
"unknown command `{command}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
"unknown command `{command}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`"
)
);
}
File diff suppressed because it is too large Load Diff
@@ -506,7 +506,7 @@ impl Drop for AbortServer {
async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real_http() {
let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await;
seed_test_api_token(api.store.as_ref(), "drive-client");
seed_test_workspace_owner_token(api.store.as_ref(), "drive-client").await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let app = build_router(api);
@@ -32,6 +32,7 @@ struct RealDriveFixture {
api: WorkspaceApi,
owner: String,
second: String,
foreign: String,
_temp: tempfile::TempDir,
}
impl RealDriveFixture {
@@ -41,7 +42,7 @@ impl RealDriveFixture {
config.repositories.clear();
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
let owner = seed_test_api_token(store.as_ref(), "drive-web-owner");
let second = seed_test_api_token(store.as_ref(), "drive-web-second");
let foreign = seed_test_api_token(store.as_ref(), "drive-web-foreign");
let server_api = WorkspaceServerApi::new_for_test(config, store.clone());
let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await;
// Exercise T-717's real repositoryless Workspace bootstrap, not a
@@ -56,6 +57,25 @@ impl RealDriveFixture {
let created = http_json(response, StatusCode::CREATED).await;
assert!(created["repository"].is_null(), "{created}");
let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap();
// Both concurrent clients need current owner authority. A foreign
// Account must be rejected by admission before it can reach Drive CAS.
let second = "api-token-drive-web-second".to_string();
store
.create_api_token(&ApiTokenRecord {
token_hash: crate::auth::token_hash(&second),
token_id: "drive-web-second-token".into(),
user_id: store
.get_user_by_handle("user-drive-web-owner")
.unwrap()
.unwrap()
.user_id,
label: "second owner client".into(),
created_at: "2026-01-01T00:00:00Z".into(),
expires_at: None,
last_used_at: None,
revoked_at: None,
})
.unwrap();
assert!(store.list_repositories(workspace_id).unwrap().is_empty());
let api = server_api
.api_for_workspace(workspace_id)
@@ -76,6 +96,7 @@ impl RealDriveFixture {
api,
owner,
second,
foreign,
_temp: temp,
}
}
@@ -405,7 +426,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
.expect("Deno must be installed for this explicit process test");
let wire = serde_json::to_vec(&json!({
"origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(),
"owner_token":fixture.owner,"second_token":fixture.second,"worker_entry":worker_entry,
"owner_token":fixture.owner,"second_token":fixture.second,"foreign_token":fixture.foreign,"worker_entry":worker_entry,
}))
.unwrap();
let mut stdin = child.stdin.take().unwrap();
@@ -422,6 +443,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
String::from_utf8_lossy(bytes)
.replace(&fixture.owner, "[redacted]")
.replace(&fixture.second, "[redacted]")
.replace(&fixture.foreign, "[redacted]")
};
assert!(
output.status.success(),
@@ -0,0 +1,318 @@
// Production generated routes, SQLite and WebAuthn verifier share the passkey
// security fixture. Only the authenticator is SoftPasskey, not a browser/OS key.
// The ordinary Server test target selects these isolated integration cases.
impl Fixture {
async fn activation_admin(&self) -> String {
let (cookie, user) = self
.enroll(
"registration",
None,
"activation-admin",
&mut authenticator(),
)
.await;
self.api
.config_store
.setup_hosting_admin(&user.handle, false)
.unwrap();
let actor = RequestActor {
user_id: user.user_id,
account_id: user.account_id,
handle: user.handle,
display_name: user.display_name,
auth_method: ActorAuthMethod::BrowserSession,
};
self.api
.store
.set_hosting_self_registration_enabled(&actor, false)
.unwrap();
cookie
}
async fn issue_activation(
&self,
admin: &str,
handle: &str,
) -> server_api::ActivationCodeResponse {
let (status, headers, body) = self
.post(
"/api/hosting/accounts",
Some(admin),
Some(ORIGIN_URL),
serde_json::json!({"handle":handle}),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"code issuance must succeed without printing its response"
);
assert_eq!(headers[CACHE_CONTROL], "no-store");
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
serde_json::from_value(body).unwrap()
}
async fn activation_options(
&self,
issued: &server_api::ActivationCodeResponse,
) -> PasskeyRegistrationOptionsResponse {
let (status, headers, body) = self
.post(
"/api/auth/activation/options",
None,
Some(ORIGIN_URL),
serde_json::json!({"handle": issued.user.handle, "code": issued.code}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[CACHE_CONTROL], "no-store");
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert!(body.get("access_token").is_none());
serde_json::from_value(body).unwrap()
}
}
#[tokio::test]
async fn activation_requires_verified_passkey_and_never_grants_hosting_authority() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "pending-person").await;
assert!(!f.api.store.hosting_self_registration_enabled().unwrap());
let abandoned = f.activation_options(&issued).await;
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
assert!(f.credentials(&issued.user).is_empty());
rejected(
f.post(
"/api/auth/passkeys/login/options",
None,
None,
serde_json::json!({"handle":issued.user.handle}),
)
.await,
StatusCode::BAD_REQUEST,
"passkey",
);
// A valid attestation for a different challenge cannot activate this identity.
let options = f.activation_options(&issued).await;
let mut tampered = Fixture::credential(abandoned, &mut authenticator());
tampered["challenge_id"] = Value::String(options.challenge_id);
rejected(
f.post("/api/auth/activation/complete", None, None, tampered)
.await,
StatusCode::BAD_REQUEST,
"webauthn_registration_verification_failed",
);
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
assert!(f.credentials(&issued.user).is_empty());
// Cancellation/failure did not spend the activation code. Fresh options resume.
let options = f.activation_options(&issued).await;
let mut key = authenticator();
let complete = Fixture::credential(options, &mut key);
let (status, headers, body) = f
.post(
"/api/auth/activation/complete",
None,
None,
complete.clone(),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(body["user"]["user_id"], issued.user.user_id);
assert!(
!f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
assert!(!f.api.store.is_hosting_admin(&issued.user.user_id).unwrap());
assert_eq!(f.credentials(&issued.user).len(), 1);
rejected(
f.post("/api/auth/activation/complete", None, None, complete)
.await,
StatusCode::BAD_REQUEST,
"invalid_passkey_challenge",
);
let (status, headers, _) = f
.post(
"/api/auth/activation/options",
None,
None,
serde_json::json!({"handle":issued.user.handle,"code":issued.code}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(f.login("pending-person", &mut key).await, issued.user);
}
#[tokio::test]
async fn activation_ceremonies_cannot_be_used_by_registration_or_addition_endpoints() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "purpose-bound").await;
for endpoint in [
"/api/auth/passkeys/registration/complete",
"/api/auth/passkeys/addition/complete",
] {
let options = f.activation_options(&issued).await;
let complete = Fixture::credential(options, &mut authenticator());
let (status, _, _) = f
.post(endpoint, Some(&admin), Some(ORIGIN_URL), complete)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(f.credentials(&issued.user).is_empty());
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
}
// The converse also fails: even a logged-in administrator's addition ceremony
// must not become an activation or create credentials for the provisional user.
let options = f.options("addition", Some(&admin), "ignored").await;
let complete = Fixture::credential(options, &mut authenticator());
rejected(
f.post(
"/api/auth/activation/complete",
Some(&admin),
Some(ORIGIN_URL),
complete,
)
.await,
StatusCode::BAD_REQUEST,
"passkey_registration_binding_mismatch",
);
assert!(f.credentials(&issued.user).is_empty());
}
#[tokio::test]
async fn activation_reissue_invalidates_even_a_previously_verified_code_ceremony() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "rotated").await;
let options = f.activation_options(&issued).await;
let complete = Fixture::credential(options, &mut authenticator());
let (status, headers, body) = f
.post(
"/api/hosting/accounts/activation-code",
Some(&admin),
Some(ORIGIN_URL),
serde_json::json!({"handle":"rotated"}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[CACHE_CONTROL], "no-store");
let replacement: server_api::ActivationCodeResponse = serde_json::from_value(body).unwrap();
assert!(
issued.code != replacement.code,
"reissue must replace the secret"
);
let (status, _, _) = f
.post("/api/auth/activation/complete", None, None, complete)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(f.credentials(&issued.user).is_empty());
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
let _ = f.activation_options(&replacement).await;
}
#[tokio::test]
async fn activation_two_valid_ceremonies_complete_once_and_save_no_extra_credential() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "one-winner").await;
let first = Fixture::credential(f.activation_options(&issued).await, &mut authenticator());
let second = Fixture::credential(f.activation_options(&issued).await, &mut authenticator());
let (a, b) = tokio::join!(
f.post("/api/auth/activation/complete", None, None, first),
f.post("/api/auth/activation/complete", None, None, second)
);
assert_eq!(
[a.0, b.0]
.iter()
.filter(|status| **status == StatusCode::OK)
.count(),
1
);
assert_eq!(
[a.0, b.0]
.iter()
.filter(|status| **status == StatusCode::BAD_REQUEST)
.count(),
1
);
assert_eq!(f.credentials(&issued.user).len(), 1);
}
#[tokio::test]
async fn activation_direct_admin_mutations_reject_foreign_origin_even_with_live_bearer() {
use api_macros::HttpError;
use server_api::ServerApi;
let f = Fixture::new().await;
let cookie = f.activation_admin().await;
let token = f.device_token(&cookie).await;
let service = ServerApiContractService::Workspace(f.api.clone());
let context = |origin: Option<&str>| server_api::ServerRequestContext {
actor: None,
worker_source: None,
runtime_source: None,
// A forged extension cannot override the byte-exact transport Origin.
origin: Some(ORIGIN_URL.into()),
transport_headers: std::iter::once((
"authorization".into(),
format!("Bearer {token}").into_bytes(),
))
.chain(origin.map(|value| ("origin".into(), value.as_bytes().to_vec())))
.collect(),
};
for handle in ["new-id", "existing-pending"] {
let request = || server_api::ProvisionalAccountRequest {
handle: handle.into(),
};
assert_eq!(
service
.hosting_account_create(context(Some("https://foreign.test")), request())
.await
.unwrap_err()
.status_code(),
403
);
assert_eq!(
service
.hosting_activation_code_reissue(context(Some("https://foreign.test")), request())
.await
.unwrap_err()
.status_code(),
403
);
assert!(f.api.store.get_user_by_handle(handle).unwrap().is_none());
}
let issued = service
.hosting_account_create(
context(None),
server_api::ProvisionalAccountRequest {
handle: "non-browser-bearer".into(),
},
)
.await
.unwrap();
assert_eq!(issued.user.handle, "non-browser-bearer");
}
@@ -195,7 +195,8 @@ async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_sta
} else {
None
};
let token = seed_test_api_token(api.store.as_ref(), "partial-start-owner");
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "partial-start-owner").await;
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new(
@@ -349,7 +350,8 @@ async fn unfinished_dependencies_allow_read_only_start_and_assign_without_write_
assign_test_orchestrator(api, &fixture.ticket_id);
}
let delegation = observed_start_delegation(api, &fixture.ticket_id, source.as_ref());
let token = seed_test_api_token(api.store.as_ref(), "read-only-start-owner");
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "read-only-start-owner").await;
let worker = if existing {
// Drop the unrelated checkout so the entire attachment set is reference-only.
let main = api
@@ -482,7 +484,7 @@ async fn unfinished_dependencies_do_not_bypass_http_auth_closed_or_resource_bind
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
add_unfinished_start_dependency(api, &fixture.ticket_id);
let token = seed_test_api_token(api.store.as_ref(), "guard-start-owner");
let token = seed_test_workspace_owner_token(api.store.as_ref(), "guard-start-owner").await;
let path = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker",
fixture.ticket_id
@@ -544,7 +546,8 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
let runtime = WorkdirlessFixtureRuntime::default();
api.runtime.register_or_replace(runtime.clone());
let source = start_source(&mut api, &runtime).await;
let token = seed_test_api_token(api.store.as_ref(), "delegation-owner");
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "delegation-owner").await;
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Observed delegation start"))
@@ -665,7 +668,8 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
let runtime = WorkdirlessFixtureRuntime::default();
api.runtime.register_or_replace(runtime.clone());
let source = start_source(&mut api, &runtime).await;
let token = seed_test_api_token(api.store.as_ref(), "obsolete-worker-owner");
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new(
@@ -5,7 +5,8 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
for runtime_source in [true, false] {
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "assign-replay-owner");
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "assign-replay-owner").await;
let request = |method: &str, path: &str, body: Vec<u8>| {
if runtime_source {
runtime_source_request(&identity, Some(&source.worker_id), method, path, body)
@@ -370,7 +371,8 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
{
let dir = tempfile::tempdir().unwrap();
let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await;
let owner = seed_test_api_token(api.store.as_ref(), "assignment-account-owner");
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
@@ -393,7 +395,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
let actor = format!(
"workspace-user:{}",
serde_json::to_string(&(
"account-assignment-account-owner",
&format!("account-{TEST_WORKSPACE_ID}"),
"user-assignment-account-owner"
))
.unwrap()
@@ -411,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
tracking_response(
app.clone(),
request(&other, "PUT", &path, body),
StatusCode::CONFLICT,
StatusCode::FORBIDDEN,
)
.await;
let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap();
@@ -434,7 +436,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
tracking_response(
app,
request(&other, "DELETE", &clear_path, Vec::new()),
StatusCode::CONFLICT,
StatusCode::FORBIDDEN,
)
.await;
let released = api
@@ -1105,7 +1107,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let replay = assign_tracking(&api, &identity, &source, &ticket).await;
assert_eq!(replay, current);
let token = seed_test_api_token(api.store.as_ref(), "assignment-replay-user");
let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
tracking_response(
@@ -1226,7 +1228,8 @@ async fn tracking_model_authorship_and_role_fields_are_rejected_and_human_is_not
)
.await;
}
let token = seed_test_api_token(api.store.as_ref(), "human-not-tracking-agent");
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "human-not-tracking-agent").await;
tracking_response(
app,
Request::builder()
@@ -1308,7 +1311,7 @@ async fn assignment_clear_receipt_binds_verified_actor_and_runtime_binding() {
.await,
result
);
let token = seed_test_api_token(api.store.as_ref(), "another-clear-actor");
let token = seed_test_workspace_owner_token(api.store.as_ref(), "another-clear-actor").await;
tracking_response(
app.clone(),
Request::builder()
@@ -1517,7 +1520,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let token = seed_test_api_token(api.store.as_ref(), "answer-owner");
let token = seed_test_workspace_owner_token(api.store.as_ref(), "answer-owner").await;
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"});
@@ -1755,7 +1758,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
seed_test_api_token(api.store.as_ref(), "assignment-cookie");
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-cookie").await;
api.store
.create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash("assignment-browser-session"),
@@ -1846,7 +1849,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
.header(ORIGIN, origin)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
StatusCode::NOT_FOUND,
StatusCode::FORBIDDEN,
)
.await;
}
@@ -1865,7 +1868,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
.author
.as_deref()
.unwrap()
.contains("account-assignment-cookie")
.contains(&format!("account-{TEST_WORKSPACE_ID}"))
);
}
@@ -577,7 +577,7 @@ async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_recei
async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "case-human");
let token = seed_test_workspace_owner_token(api.store.as_ref(), "case-human").await;
let other_token = seed_test_api_token(api.store.as_ref(), "other-human");
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
@@ -613,11 +613,17 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await;
let closed = backend.show(reference.id.clone().into()).unwrap();
let event = closed.events.last().unwrap();
assert_eq!(event.attributes["source_account_id"], "account-case-human");
assert_eq!(
event.attributes["source_account_id"],
format!("account-{TEST_WORKSPACE_ID}")
);
assert_eq!(event.attributes["source_user_id"], "user-case-human");
assert_eq!(
event.author.as_deref(),
Some("workspace-user:[\"account-case-human\",\"user-case-human\"]")
Some(
format!("workspace-user:[\"account-{TEST_WORKSPACE_ID}\",\"user-case-human\"]")
.as_str()
)
);
assert_eq!(
closed.case_history.last().unwrap().author,
@@ -630,7 +636,7 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
case_response(
typed,
human_request(&other_token, true),
StatusCode::CONFLICT,
StatusCode::FORBIDDEN,
)
.await;
// Generic dispatch receives an explicit untrusted author but must override it.
@@ -858,16 +858,15 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
)
.await;
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
// Classification follows shared Ticket Workspace write authority, not the
// owner-only repository/secrets-management policy. A distinct authenticated
// actor may manage tags but cannot replay another actor's receipt.
// An authenticated foreign account cannot mutate classification or replay
// the owner's receipt; classification does not grant Workspace authority.
request_json_authenticated(
app.clone(),
"POST",
&path,
Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})),
&collaborator_token,
StatusCode::CONFLICT,
StatusCode::FORBIDDEN,
)
.await;
request_json_authenticated(
@@ -876,7 +875,7 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
"/api/w/other-workspace/ticket-tags",
Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})),
&collaborator_token,
StatusCode::NOT_FOUND,
StatusCode::FORBIDDEN,
)
.await;
let response = app
+653 -12
View File
@@ -15,6 +15,38 @@
//! migration before it starts. Updating only the new schema would diverge from existing DBs;
//! both creation paths must maintain the same contract.
//!
//! # Hosting administrator setup
//!
//! Fresh and migrated databases have no hosting administrators and disable public
//! self-registration. Users, Workspace owners and holders of the legacy bootstrap token
//! are not promoted automatically. Hosting grants are separate from Workspace permissions.
//!
//! On the trusted Server host, run `yoi-server hosting-admin --database <PATH> --handle
//! <NEW_HANDLE> --create-user` for a fresh installation. This atomically creates a user
//! and explicit hosting grant, and intentionally prints one bearer token, valid for
//! fifteen minutes. Keep stdout private: the token is a full user bearer, not a scoped
//! activation code. Start the Server with its normal Web static assets and configured
//! WebAuthn origin (HTTPS, or localhost). Open `/hosting/setup` on that trusted origin,
//! enter the host-issued token in its password field, and choose Add administrator
//! passkey. The page uses actor-derived addition, not public registration; it never
//! stores the token or puts it in URLs. After enrollment choose Sign in with new
//! passkey, then Open hosting settings. No Workspace or public registration is needed.
//! The initial bearer expires automatically; never put it in URLs, docs or shared logs.
//!
//! For an existing installation, back up the DB and run migrations, then explicitly
//! select a known user with `yoi-server hosting-admin --database <PATH> --handle
//! <EXISTING_HANDLE>` without `--create-user`. Verify the handle belongs to the intended
//! person first. This grants authority without minting or replacing login credentials;
//! that person uses their existing passkey. `bootstrap-auth` retains its old host-user
//! setup behavior but grants no hosting authority.
//!
//! Repeated creation refuses an existing handle and never refreshes a token. If the
//! initial token is lost or expires before enrollment, a trusted host operator can
//! explicitly create a different administrator handle and enroll it, then remove the
//! unused user's grant and tokens through trusted DB maintenance after a backup.
//! Selecting an existing user is not a credential recovery mechanism; no public
//! bootstrap or unauthenticated administrator recovery endpoint exists.
//!
//! # Resource state and operation records
//!
//! Workspace bootstrap commits resources and creation receipts in one transaction. Jobs and
@@ -60,6 +92,9 @@ use crate::backend_job::{
use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
#[path = "activation.rs"]
mod activation;
pub use activation::ActivationCodeIssue;
#[path = "assignment_delivery.rs"]
mod assignment_delivery;
#[path = "notification_diagnostics.rs"]
@@ -84,7 +119,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 98;
const LATEST_SCHEMA_VERSION: i64 = 100;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -382,6 +417,16 @@ const MIGRATIONS: &[Migration] = &[
name: "Assignment-independent implementation reports",
apply: migrate_result_reports_v97_to_v98,
},
Migration {
version: 99,
name: "Explicit hosting administrators and self-registration policy",
apply: migrate_hosting_policy_v98_to_v99,
},
Migration {
version: 100,
name: "Provisional users and scoped passkey activation",
apply: activation::migrate,
},
];
#[derive(Clone, Copy)]
@@ -674,6 +719,10 @@ pub enum PasskeyRegistrationTarget {
NewAccount {
user: UserRecord,
},
Activation {
user: UserRecord,
issuance_id: String,
},
Addition {
user: UserRecord,
authentication: PasskeyAuthenticationBinding,
@@ -1222,6 +1271,17 @@ impl WorkspaceResourceKind {
}
}
/// Result of an explicitly trusted host setup. Secrets deliberately have no Debug output.
pub struct HostingAdminSetup {
pub user: UserRecord,
pub initial_bearer: Option<HostingAdminInitialBearer>,
}
pub struct HostingAdminInitialBearer {
pub access_token: String,
pub expires_at: String,
}
#[async_trait]
pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
/// Bounded owner for whole synchronous operations from async callers. Move owned inputs
@@ -1615,6 +1675,28 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
fn any_user(&self) -> Result<Option<UserRecord>>;
fn create_provisional_user(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue>;
fn reissue_activation_code(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue>;
fn verify_activation_code(&self, handle: &str, code: &str)
-> Result<PasskeyRegistrationTarget>;
fn is_user_pending_activation(&self, user_id: &str) -> Result<bool>;
/// Checks an explicit grant for a currently existing user, not Workspace ownership.
fn is_hosting_admin(&self, user_id: &str) -> Result<bool>;
fn hosting_self_registration_enabled(&self) -> Result<bool>;
/// Rechecks the actor's live user/account and hosting grant in the write transaction.
fn set_hosting_self_registration_enabled(
&self,
actor: &server_api::RequestActor,
enabled: bool,
) -> Result<()>;
/// Atomically creates a new account/user/credential, or inserts a credential
/// for a still-authenticated existing target. Never overwrites a credential.
fn register_verified_passkey(
@@ -2608,6 +2690,64 @@ impl SqliteWorkspaceStore {
})
}
/// Host-only setup; never expose this operation through a public bootstrap route.
/// Creating a user, granting hosting authority and issuing its short-lived initial
/// bearer are atomic. Selecting an existing user never issues credentials. A repeat
/// create is refused, including after output loss; it cannot mint another bearer.
pub fn setup_hosting_admin(
&self,
handle: &str,
create_user: bool,
) -> Result<HostingAdminSetup> {
let handle = crate::auth::normalize_handle(handle)?;
self.with_immediate_transaction(|tx| {
let existing = tx.query_row(
user_select_sql("WHERE handle = ?1").as_str(),
[&handle], read_user_record,
).optional()?;
let now = chrono::Utc::now();
let created_at = now.to_rfc3339();
let (user, initial_bearer) = match (existing, create_user) {
(Some(_), true) => return Err(Error::Store("hosting-admin --create-user requires a new handle; no credentials issued".into())),
(None, false) => return Err(Error::Store("hosting-admin requires an existing user; use --create-user explicitly for a new user".into())),
(Some(user), false) => {
activation::require_active(tx, &user.user_id)?;
(user, None)
},
(None, true) => {
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: created_at.clone(),
updated_at: created_at.clone(),
};
tx.execute(
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?3,?4,?4)",
params![user.account_id, user.handle, user.display_name, created_at],
)?;
tx.execute(
"INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?5)",
params![user.user_id, user.account_id, user.handle, user.display_name, created_at],
)?;
let access_token = crate::auth::mint_secret("yoi_api");
let expires_at = (now + chrono::Duration::minutes(15)).to_rfc3339();
tx.execute(
"INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at,expires_at) VALUES(?1,?2,?3,'initial hosting administrator passkey setup',?4,?5)",
params![crate::auth::new_id("api-token"), crate::auth::token_hash(&access_token), user.user_id, created_at, expires_at],
)?;
(user, Some(HostingAdminInitialBearer { access_token, expires_at }))
}
};
tx.execute(
"INSERT INTO hosting_administrators(user_id,created_at) VALUES(?1,?2) ON CONFLICT(user_id) DO NOTHING",
params![user.user_id, created_at],
)?;
Ok(HostingAdminSetup { user, initial_bearer })
})
}
pub(crate) fn get_internal_worker_restore_intent(
&self,
workspace_id: &str,
@@ -4314,6 +4454,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|row| row.get::<_, String>(0),
)
.optional()?;
activation::require_active_account(&tx, &record.owner_account_id)?;
if owner_kind.as_deref() != Some("user") {
return Err(Error::Store(format!(
"Workspace owner `{}` must reference an existing User Account",
@@ -4495,6 +4636,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|row| row.get::<_, String>(0),
)
.optional()?;
activation::require_active_account(&tx, &record.workspace.owner_account_id)?;
if owner_kind.as_deref() != Some("user") {
return Err(Error::Store(format!(
"Workspace owner `{}` must reference an existing User Account",
@@ -6447,6 +6589,83 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn create_provisional_user(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue> {
activation::create(self, actor, handle)
}
fn reissue_activation_code(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue> {
activation::reissue(self, actor, handle)
}
fn verify_activation_code(
&self,
handle: &str,
code: &str,
) -> Result<PasskeyRegistrationTarget> {
activation::verify(self, handle, code)
}
fn is_user_pending_activation(&self, user_id: &str) -> Result<bool> {
self.with_conn(|conn| activation::pending(conn, user_id))
}
fn is_hosting_admin(&self, user_id: &str) -> Result<bool> {
self.with_conn(|conn| {
if activation::pending(conn, user_id)? {
return Ok(false);
}
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1)",
[user_id], |row| row.get(0),
).map_err(Error::from)
})
}
fn hosting_self_registration_enabled(&self) -> Result<bool> {
self.with_conn(|conn| {
conn.query_row(
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
[],
|row| row.get(0),
)
.map_err(Error::from)
})
}
fn set_hosting_self_registration_enabled(
&self,
actor: &server_api::RequestActor,
enabled: bool,
) -> Result<()> {
self.with_immediate_transaction(|tx| {
activation::require_active(tx, &actor.user_id)?;
let allowed: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1 AND u.account_id = ?2)",
params![actor.user_id, actor.account_id], |row| row.get(0),
)?;
if !allowed {
return Err(Error::WorkspacePermissionDenied(
"hosting_admin_required: a live hosting administrator is required".into(),
));
}
if tx.execute(
"UPDATE hosting_settings SET self_registration_enabled = ?1 WHERE singleton = 1",
[enabled],
)? != 1 {
return Err(Error::Store("hosting settings singleton is missing".into()));
}
Ok(())
})
}
fn register_verified_passkey(
&self,
target: &PasskeyRegistrationTarget,
@@ -6455,6 +6674,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_immediate_transaction(|tx| {
let user = match target {
PasskeyRegistrationTarget::NewAccount { user } => {
let enabled: bool = tx.query_row(
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
[], |row| row.get(0),
)?;
if !enabled {
return Err(Error::WorkspacePermissionDenied(
"self_registration_disabled: hosting self-registration is disabled".into(),
));
}
let occupied: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)",
params![user.handle], |row| row.get(0),
@@ -6472,7 +6700,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)?;
user
}
PasskeyRegistrationTarget::Activation { user, issuance_id } => {
activation::require_pending_target(tx, user, Some(issuance_id))?;
user
}
PasskeyRegistrationTarget::Addition { user, authentication } => {
activation::require_active(tx, &user.user_id)?;
let current = tx.query_row(
user_select_sql("WHERE user_id = ?1 AND account_id = ?2").as_str(),
params![user.user_id, user.account_id], read_user_record,
@@ -6503,12 +6736,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
)?;
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
}
}
Ok(())
})
}
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
conn.execute(
r#"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
@@ -6618,7 +6857,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
conn.execute(
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
@@ -6631,7 +6871,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn resolve_browser_session(&self, token_hash: &str) -> Result<Option<BrowserSessionRecord>> {
self.with_conn(|conn| {
conn.query_row(
browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(),
browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL AND NOT EXISTS (SELECT 1 FROM user_activations p WHERE p.user_id=browser_sessions.user_id)").as_str(),
params![token_hash],
read_browser_session_record,
)
@@ -6651,7 +6891,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
conn.execute(
r#"INSERT INTO api_tokens (token_id, token_hash, user_id, label, created_at, expires_at, revoked_at, last_used_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
@@ -6664,7 +6905,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>> {
self.with_conn(|conn| {
conn.query_row(
api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(),
api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL AND NOT EXISTS (SELECT 1 FROM user_activations p WHERE p.user_id=api_tokens.user_id)").as_str(),
params![token_hash],
read_api_token_record,
)
@@ -6684,7 +6925,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
if let Some(user_id) = &record.user_id { activation::require_active(conn, user_id)?; }
let changed = conn.execute(
r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
@@ -6733,7 +6975,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
issued_access_token: &str,
approved_at: &str,
) -> Result<bool> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, user_id)?;
let changed = conn.execute(
r#"UPDATE device_login_flows
SET user_id = ?2, api_token_id = ?3, issued_access_token = ?4, approved_at = ?5
@@ -6755,8 +6998,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
device_code: &str,
consumed_at: &str,
) -> Result<Option<DeviceLoginFlowRecord>> {
self.with_conn(|conn| {
let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL").as_str(), params![device_code], read_device_login_flow_record).optional()?;
self.with_immediate_transaction(|conn| {
let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_activations p WHERE p.user_id=device_login_flows.user_id)").as_str(), params![device_code], read_device_login_flow_record).optional()?;
if record.is_some() {
conn.execute("UPDATE device_login_flows SET consumed_at = ?2, issued_access_token = NULL WHERE device_code = ?1", params![device_code, consumed_at])?;
}
@@ -14597,6 +14840,33 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 98 {
return Err(Error::Store(
"Hosting policy migration requires schema 98".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(
"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);",
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(99,?1)",
["Explicit hosting administrators and self-registration policy"],
)?;
verify_workspace_resource_constraints(&tx)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
@@ -14615,7 +14885,9 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below.
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?;
Ok(())
}
fn create_workspace_schema_structure_from_sql(
@@ -17501,6 +17773,369 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
Ok(())
}
fn hosting_user(handle: &str) -> UserRecord {
UserRecord {
user_id: format!("user-{handle}"),
account_id: format!("account-{handle}"),
handle: handle.into(),
display_name: handle.into(),
created_at: "1".into(),
updated_at: "1".into(),
}
}
fn hosting_seed_user(store: &SqliteWorkspaceStore, handle: &str) -> UserRecord {
let user = hosting_user(handle);
store
.upsert_account(&AccountRecord {
account_id: user.account_id.clone(),
kind: "user".into(),
handle: user.handle.clone(),
display_name: user.display_name.clone(),
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
store.upsert_user(&user).unwrap();
user
}
fn hosting_actor(user: &UserRecord) -> server_api::RequestActor {
server_api::RequestActor {
user_id: user.user_id.clone(),
account_id: user.account_id.clone(),
handle: user.handle.clone(),
display_name: user.display_name.clone(),
auth_method: server_api::ActorAuthMethod::ApiToken,
}
}
fn hosting_credential(user: &UserRecord, id: &str) -> PasskeyCredentialRecord {
PasskeyCredentialRecord {
credential_id: id.into(),
user_id: user.user_id.clone(),
public_key_cose: "verified-key".into(),
transports_json: None,
sign_count: 0,
created_at: "1".into(),
last_used_at: None,
}
}
#[test]
fn hosting_policy_migrates_oldest_and_previous_databases_without_promoting_users() {
for baseline in [50, 98] {
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-user','Old','1','1');
INSERT INTO users VALUES('old-user','old-account','old-user','Old','1','1');").unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
assert!(!store.is_hosting_admin("old-user").unwrap());
assert_eq!(
store.get_user("old-user").unwrap().unwrap().display_name,
"Old"
);
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
for table in ["hosting_settings", "hosting_administrators"] {
let schema = |store: &SqliteWorkspaceStore| {
store
.with_conn(|conn| {
let ddl: String = conn.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[table],
|r| r.get(0),
)?;
Ok(ddl
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>())
})
.unwrap()
};
assert_eq!(schema(&store), schema(&fresh));
}
store
.with_conn(|conn| {
assert!(
conn.execute(
"INSERT INTO hosting_administrators VALUES('missing','1')",
[]
)
.is_err()
);
assert!(
conn.execute(
"UPDATE hosting_settings SET self_registration_enabled=2",
[]
)
.is_err()
);
assert!(
conn.execute("INSERT INTO hosting_settings VALUES(2,0)", [])
.is_err()
);
assert_eq!(
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(0))?,
0
);
Ok(())
})
.unwrap();
}
}
#[test]
fn hosting_policy_migration_failure_rolls_back_new_tables_and_history() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
prepare_retained_schema(&path, 98);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// A conflicting second DDL statement fails after the first table was created.
conn.execute_batch("CREATE TABLE hosting_settings(original TEXT); INSERT INTO hosting_settings VALUES('retained');").unwrap();
assert!(migrate_hosting_policy_v98_to_v99(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 98);
assert!(!table_exists(&conn, "hosting_administrators").unwrap());
assert_eq!(
conn.query_row("SELECT original FROM hosting_settings", [], |r| r
.get::<_, String>(0))
.unwrap(),
"retained"
);
conn.execute_batch("DROP TABLE hosting_settings").unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
}
#[tokio::test]
async fn hosting_policy_requires_explicit_live_admin_and_persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let owner = hosting_seed_user(&store, "owner");
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".into(),
owner_account_id: owner.account_id.clone(),
display_name: "Workspace".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
assert!(!store.is_hosting_admin(&owner.user_id).unwrap());
assert!(matches!(
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), true),
Err(Error::WorkspacePermissionDenied(_))
));
assert!(!store.hosting_self_registration_enabled().unwrap());
let setup = store.setup_hosting_admin("owner", false).unwrap();
assert!(setup.initial_bearer.is_none());
let mut forged = hosting_actor(&owner);
forged.account_id = "another-account".into();
assert!(matches!(
store.set_hosting_self_registration_enabled(&forged, true),
Err(Error::WorkspacePermissionDenied(_))
));
store
.set_hosting_self_registration_enabled(&hosting_actor(&owner), true)
.unwrap();
drop(store);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert!(store.hosting_self_registration_enabled().unwrap());
store
.with_conn(|conn| {
conn.execute("DELETE FROM hosting_administrators", [])?;
Ok(())
})
.unwrap();
assert!(matches!(
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), false),
Err(Error::WorkspacePermissionDenied(_))
));
assert!(store.hosting_self_registration_enabled().unwrap());
}
#[test]
fn hosting_setup_creates_finite_bearer_once_and_existing_selection_mints_nothing() {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
assert!(store.setup_hosting_admin("unknown", false).is_err());
let setup = store.setup_hosting_admin("new-admin", true).unwrap();
assert!(store.is_hosting_admin(&setup.user.user_id).unwrap());
let bearer = setup.initial_bearer.unwrap();
let token = store
.resolve_api_token(&crate::auth::token_hash(&bearer.access_token))
.unwrap()
.unwrap();
assert_eq!(token.user_id, setup.user.user_id);
assert_eq!(
token.expires_at.as_deref(),
Some(bearer.expires_at.as_str())
);
let lifetime = chrono::DateTime::parse_from_rfc3339(&bearer.expires_at).unwrap()
- chrono::DateTime::parse_from_rfc3339(&token.created_at).unwrap();
assert_eq!(lifetime.num_seconds(), 15 * 60);
assert!(store.setup_hosting_admin("new-admin", true).is_err());
assert!(
store
.setup_hosting_admin("new-admin", false)
.unwrap()
.initial_bearer
.is_none()
);
let existing = hosting_seed_user(&store, "existing");
assert!(!store.is_hosting_admin(&existing.user_id).unwrap());
assert!(
store
.setup_hosting_admin("existing", false)
.unwrap()
.initial_bearer
.is_none()
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row("SELECT count(*) FROM api_tokens", [], |r| r
.get::<_, i64>(0))?,
1
);
conn.execute("DELETE FROM users WHERE user_id=?1", [&setup.user.user_id])?;
Ok(())
})
.unwrap();
assert!(!store.is_hosting_admin(&setup.user.user_id).unwrap());
assert!(matches!(
store.set_hosting_self_registration_enabled(&hosting_actor(&setup.user), true),
Err(Error::WorkspacePermissionDenied(_))
));
assert!(!store.hosting_self_registration_enabled().unwrap());
}
#[test]
fn hosting_setup_failure_rolls_back_account_user_token_and_grant() {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_admin BEFORE INSERT ON hosting_administrators BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?; Ok(()) }).unwrap();
assert!(store.setup_hosting_admin("admin", true).is_err());
store
.with_conn(|conn| {
for table in ["accounts", "users", "api_tokens", "hosting_administrators"] {
assert_eq!(
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r
.get::<_, i64>(0))?,
0
);
}
Ok(())
})
.unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
}
#[test]
fn hosting_registration_rechecks_policy_but_allows_authenticated_addition() {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let admin = store.setup_hosting_admin("admin", true).unwrap();
let new_user = hosting_user("visitor");
let target = PasskeyRegistrationTarget::NewAccount {
user: new_user.clone(),
};
let credential = hosting_credential(&new_user, "visitor-key");
let assert_denied = || {
let error = store
.register_verified_passkey(&target, &credential)
.unwrap_err();
assert!(
matches!(error, Error::WorkspacePermissionDenied(ref message) if message.contains("self_registration_disabled"))
);
assert!(store.get_user(&new_user.user_id).unwrap().is_none());
assert!(store.get_account(&new_user.account_id).unwrap().is_none());
assert!(
store
.get_passkey_credential("visitor-key")
.unwrap()
.is_none()
);
};
assert_denied();
store
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
.unwrap();
// Simulate an enrollment allocated while enabled, then stopped before completion.
store
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), false)
.unwrap();
assert_denied();
let token_hash = crate::auth::token_hash(&admin.initial_bearer.unwrap().access_token);
let addition = PasskeyRegistrationTarget::Addition {
user: admin.user.clone(),
authentication: PasskeyAuthenticationBinding::ApiToken(token_hash.clone()),
};
store
.register_verified_passkey(&addition, &hosting_credential(&admin.user, "admin-key"))
.unwrap();
assert!(store.get_passkey_credential("admin-key").unwrap().is_some());
store
.with_conn(|conn| {
conn.execute(
"UPDATE api_tokens SET revoked_at=?1 WHERE token_hash=?2",
params![crate::auth::now_rfc3339(), token_hash],
)?;
Ok(())
})
.unwrap();
assert!(
store
.register_verified_passkey(
&addition,
&hosting_credential(&admin.user, "revoked-key")
)
.is_err()
);
store
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
.unwrap();
store
.register_verified_passkey(&target, &credential)
.unwrap();
assert_eq!(
store.get_user(&new_user.user_id).unwrap(),
Some(new_user.clone())
);
assert!(!store.is_hosting_admin(&new_user.user_id).unwrap());
// A duplicate credential fails after account/user insertion and must roll both back.
let failed_user = hosting_user("failed");
assert!(
store
.register_verified_passkey(
&PasskeyRegistrationTarget::NewAccount {
user: failed_user.clone()
},
&hosting_credential(&failed_user, "visitor-key")
)
.is_err()
);
assert!(store.get_user(&failed_user.user_id).unwrap().is_none());
assert!(
store
.get_account(&failed_user.account_id)
.unwrap()
.is_none()
);
}
include!("activation_tests.rs");
include!("schema_equivalence_tests.rs");
include!("ticket_tag_upgrade_tests.rs");
include!("ticket_post_upgrade_tests.rs");
@@ -17910,6 +18545,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()},
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()},
WorkspaceSchemaMigrationStep {version:99, name:"Explicit hosting administrators and self-registration policy".into()},
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
]
);
@@ -18018,6 +18655,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(96, "Backend Job common notification routing".to_string()),
(97, "MR notifications and authenticated implementation results".to_string()),
(98, "Assignment-independent implementation reports".to_string()),
(99, "Explicit hosting administrators and self-registration policy".to_string()),
(100, "Provisional users and scoped passkey activation".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -20631,7 +21270,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(95, "Common notification routing and durable Worker delivery".into()),
(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 unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}