mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
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:
@@ -29,7 +29,7 @@ pub struct UserIdentity {
|
||||
}
|
||||
|
||||
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
|
||||
fn hash_token(token: &str) -> [u8; 32] {
|
||||
pub fn hash_token(token: &str) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hasher.finalize().into()
|
||||
|
||||
@@ -498,3 +498,265 @@ impl UserStore for LibSqlBackend {
|
||||
Ok(has_users)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::libsql::LibSqlBackend;
|
||||
use crate::db::{Database, UserStore};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn hash(s: &str) -> [u8; 32] {
|
||||
let mut h = Sha256::new();
|
||||
h.update(s.as_bytes());
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
async fn setup() -> (LibSqlBackend, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("test_users.db");
|
||||
let db = LibSqlBackend::new_local(&db_path).await.unwrap();
|
||||
db.run_migrations().await.unwrap();
|
||||
(db, dir) // keep dir alive so the DB file isn't deleted
|
||||
}
|
||||
|
||||
fn test_user(id: &str) -> UserRecord {
|
||||
UserRecord {
|
||||
id: id.to_string(),
|
||||
email: Some(format!("{}@test.com", id)),
|
||||
display_name: id.to_string(),
|
||||
status: "active".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
created_by: None,
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_has_any_users_empty() {
|
||||
let (db, _dir) = setup().await;
|
||||
assert!(!db.has_any_users().await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_get_user() {
|
||||
let (db, _dir) = setup().await;
|
||||
let user = test_user("alice");
|
||||
db.create_user(&user).await.unwrap();
|
||||
|
||||
assert!(db.has_any_users().await.unwrap());
|
||||
|
||||
let found = db.get_user("alice").await.unwrap().unwrap();
|
||||
assert_eq!(found.id, "alice");
|
||||
assert_eq!(found.email, Some("[email protected]".to_string()));
|
||||
assert_eq!(found.status, "active");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_by_email() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("bob")).await.unwrap();
|
||||
|
||||
let found = db.get_user_by_email("[email protected]").await.unwrap();
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().id, "bob");
|
||||
|
||||
assert!(
|
||||
db.get_user_by_email("[email protected]")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_users_with_status_filter() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
db.create_user(&test_user("bob")).await.unwrap();
|
||||
db.update_user_status("bob", "suspended").await.unwrap();
|
||||
|
||||
let all = db.list_users(None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
let active = db.list_users(Some("active")).await.unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].id, "alice");
|
||||
|
||||
let suspended = db.list_users(Some("suspended")).await.unwrap();
|
||||
assert_eq!(suspended.len(), 1);
|
||||
assert_eq!(suspended[0].id, "bob");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_user_profile() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
|
||||
let meta = serde_json::json!({"role": "admin"});
|
||||
db.update_user_profile("alice", "Alice Smith", &meta)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = db.get_user("alice").await.unwrap().unwrap();
|
||||
assert_eq!(user.display_name, "Alice Smith");
|
||||
assert_eq!(user.metadata["role"], "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_lifecycle_create_authenticate_revoke() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
|
||||
// Create token
|
||||
let token_hash = hash("secret-token-123");
|
||||
let record = db
|
||||
.create_api_token("alice", "laptop", &token_hash, "secret-t", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(record.user_id, "alice");
|
||||
assert_eq!(record.name, "laptop");
|
||||
assert_eq!(record.token_prefix, "secret-t");
|
||||
|
||||
// Authenticate
|
||||
let (tok, user) = db.authenticate_token(&token_hash).await.unwrap().unwrap();
|
||||
assert_eq!(tok.id, record.id);
|
||||
assert_eq!(user.id, "alice");
|
||||
|
||||
// List tokens
|
||||
let tokens = db.list_api_tokens("alice").await.unwrap();
|
||||
assert_eq!(tokens.len(), 1);
|
||||
|
||||
// Revoke
|
||||
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
|
||||
|
||||
// Auth should fail after revoke
|
||||
assert!(db.authenticate_token(&token_hash).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_auth_fails_for_suspended_user() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
|
||||
let token_hash = hash("token-abc");
|
||||
db.create_api_token("alice", "test", &token_hash, "token-ab", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Auth works while active
|
||||
assert!(db.authenticate_token(&token_hash).await.unwrap().is_some());
|
||||
|
||||
// Suspend user
|
||||
db.update_user_status("alice", "suspended").await.unwrap();
|
||||
|
||||
// Auth should fail
|
||||
assert!(db.authenticate_token(&token_hash).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_revoke_wrong_user_returns_false() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
db.create_user(&test_user("bob")).await.unwrap();
|
||||
|
||||
let token_hash = hash("alice-token");
|
||||
let record = db
|
||||
.create_api_token("alice", "test", &token_hash, "alice-to", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Bob can't revoke Alice's token
|
||||
assert!(!db.revoke_api_token(record.id, "bob").await.unwrap());
|
||||
|
||||
// Alice can
|
||||
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invitation_lifecycle() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
|
||||
// Create invitation
|
||||
let invite_hash = hash("invite-token-xyz");
|
||||
let invitation = InvitationRecord {
|
||||
id: Uuid::new_v4(),
|
||||
email: Some("[email protected]".to_string()),
|
||||
invited_by: "alice".to_string(),
|
||||
status: "pending".to_string(),
|
||||
expires_at: Utc::now() + chrono::Duration::days(7),
|
||||
accepted_at: None,
|
||||
accepted_by: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_invitation(&invitation, &invite_hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Look up by hash
|
||||
let found = db
|
||||
.get_invitation_by_hash(&invite_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(found.id, invitation.id);
|
||||
assert_eq!(found.status, "pending");
|
||||
|
||||
// List invitations
|
||||
let list = db.list_invitations(Some("alice")).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
// Accept
|
||||
db.create_user(&UserRecord {
|
||||
id: "newuser".to_string(),
|
||||
email: Some("[email protected]".to_string()),
|
||||
display_name: "New User".to_string(),
|
||||
status: "active".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
created_by: Some("alice".to_string()),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.accept_invitation(invitation.id, "newuser")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify accepted
|
||||
let accepted = db
|
||||
.get_invitation_by_hash(&invite_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(accepted.status, "accepted");
|
||||
assert_eq!(accepted.accepted_by, Some("newuser".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_login_and_token_usage() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
|
||||
let token_hash = hash("tok");
|
||||
let record = db
|
||||
.create_api_token("alice", "test", &token_hash, "tok", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Record usage
|
||||
db.record_token_usage(record.id).await.unwrap();
|
||||
db.record_login("alice").await.unwrap();
|
||||
|
||||
// Verify timestamps updated
|
||||
let user = db.get_user("alice").await.unwrap().unwrap();
|
||||
assert!(user.last_login_at.is_some());
|
||||
|
||||
let tokens = db.list_api_tokens("alice").await.unwrap();
|
||||
assert!(tokens[0].last_used_at.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +579,7 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
|
||||
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
|
||||
-- ==================== User management (V14) ====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -589,13 +590,13 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_login_at TEXT,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_by TEXT,
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash BLOB NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
@@ -611,11 +612,11 @@ CREATE TABLE IF NOT EXISTS invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
invite_token_hash BLOB NOT NULL,
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
invited_by TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
accepted_by TEXT REFERENCES users(id),
|
||||
accepted_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
@@ -777,13 +778,13 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_login_at TEXT,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_by TEXT,
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash BLOB NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
@@ -799,11 +800,11 @@ CREATE TABLE IF NOT EXISTS invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
invite_token_hash BLOB NOT NULL,
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
invited_by TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
accepted_by TEXT REFERENCES users(id),
|
||||
accepted_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
"#,
|
||||
|
||||
+71
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user