chore: merge develop into hare/develop
This commit is contained in:
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user