feat: startup env-var user migration + UserStore integration tests

Completes the DB-backed user management feature (#1605):

- Startup migration: when GATEWAY_USER_TOKENS is set and the users
  table is empty, inserts env-var users + hashed tokens into DB.
  Logs deprecation notice when DB already has users.
- hash_token made pub for reuse in migration code.
- 10 integration tests for UserStore (libsql file-backed):
  - has_any_users bootstrap detection
  - create/get/get_by_email/list/update user lifecycle
  - token create → authenticate → revoke → reject cycle
  - suspended user tokens rejected
  - wrong-user token revoke returns false
  - invitation create → accept → user created
  - record_login and record_token_usage timestamps
- libSQL migration: removed FK constraints from V14 (incompatible
  with execute_batch inside transactions). Tables in both base SCHEMA
  and incremental migration for fresh and existing databases.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-24 14:46:20 -07:00
co-authored by Claude Opus 4.6
parent 27c43e185f
commit 240cee82e1
4 changed files with 343 additions and 9 deletions
+71
View File
@@ -591,6 +591,77 @@ async fn async_main() -> anyhow::Result<()> {
let mut gateway_url: Option<String> = None;
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
if let Some(ref gw_config) = config.channels.gateway {
// Migrate env-var users into DB on first run. If GATEWAY_USER_TOKENS is
// set and the users table is empty, insert the env-var users so they
// survive a switch to DB-backed auth.
if let (Some(user_tokens), Some(db)) = (&gw_config.user_tokens, &components.db) {
match db.has_any_users().await {
Ok(false) => {
tracing::info!(
"Migrating {} env-var users into database",
user_tokens.len()
);
for (token, cfg) in user_tokens {
use ironclaw::channels::web::auth::hash_token;
let now = chrono::Utc::now();
let user = ironclaw::db::UserRecord {
id: cfg.user_id.clone(),
email: None,
display_name: cfg.user_id.clone(),
status: "active".to_string(),
created_at: now,
updated_at: now,
last_login_at: None,
created_by: None,
metadata: serde_json::json!({"source": "env_migration"}),
};
if let Err(e) = db.create_user(&user).await {
tracing::warn!(
user_id = cfg.user_id,
"Failed to migrate user to DB: {}",
e
);
continue;
}
let token_hash = hash_token(token);
let prefix = if token.len() >= 8 {
&token[..8]
} else {
token.as_str()
};
if let Err(e) = db
.create_api_token(
&cfg.user_id,
"env-migrated",
&token_hash,
prefix,
None,
)
.await
{
tracing::warn!(
user_id = cfg.user_id,
"Failed to migrate token to DB: {}",
e
);
}
}
tracing::info!("Env-var user migration complete");
}
Ok(true) => {
tracing::info!(
"GATEWAY_USER_TOKENS is set but DB already has users — \
env-var tokens will be checked first, DB tokens as fallback. \
Consider removing GATEWAY_USER_TOKENS and managing users via \
/api/admin/users and /api/tokens endpoints."
);
}
Err(e) => {
tracing::warn!("Could not check for existing users: {}", e);
}
}
}
// Build multi-user auth state if user_tokens is configured, else single-user.
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};