mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 00:29:24 +00:00
refactor: remove GATEWAY_USER_TOKENS, fix review feedback
GATEWAY_USER_TOKENS never went to production — replaced entirely by DB-backed user management via /api/admin/users and /api/tokens. Removed: - UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing - user_tokens field from GatewayConfig - GatewayChannel::new_multi_auth() constructor - Env-var user migration block in main.rs (~90 lines) - multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime via db.has_any_users() in app.rs) Review fixes (zmanian): - User ID generation: UUID instead of display-name derivation (#1) - Invitation accept moved to public router (no auth needed) (#3) - libSQL get_invitation_by_hash aligned with postgres: filters status='pending' AND expires_at > now (#4) - UUID parse: returns DatabaseError::Serialization instead of unwrap_or_default (#7) - PostgreSQL SELECT * replaced with explicit column lists (#8) - Sort order aligned (both backends use DESC) (#6) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
+2
-7
@@ -342,17 +342,12 @@ impl AppBuilder {
|
|||||||
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
||||||
let ws = Arc::new(ws);
|
let ws = Arc::new(ws);
|
||||||
|
|
||||||
// Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
|
// Detect multi-tenant mode: when the database has registered users,
|
||||||
// each authenticated user needs their own workspace scope. Use
|
// each authenticated user needs their own workspace scope. Use
|
||||||
// WorkspacePool (which implements WorkspaceResolver) to create
|
// WorkspacePool (which implements WorkspaceResolver) to create
|
||||||
// per-user workspaces on demand instead of sharing the startup
|
// per-user workspaces on demand instead of sharing the startup
|
||||||
// workspace across all users.
|
// workspace across all users.
|
||||||
let is_multi_tenant = self
|
let is_multi_tenant = db.has_any_users().await.unwrap_or(false);
|
||||||
.config
|
|
||||||
.channels
|
|
||||||
.gateway
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|gw| gw.user_tokens.is_some());
|
|
||||||
|
|
||||||
if is_multi_tenant {
|
if is_multi_tenant {
|
||||||
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
|
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ impl MultiAuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a multi-user auth state from a map of tokens to identities.
|
/// Create a multi-user auth state from a map of tokens to identities.
|
||||||
|
///
|
||||||
|
/// **Test-only** — production multi-user auth is DB-backed via
|
||||||
|
/// `DbAuthenticator`. This constructor is kept public (not `#[cfg(test)]`)
|
||||||
|
/// because integration tests in `tests/` compile the crate as a library
|
||||||
|
/// where `cfg(test)` is not set.
|
||||||
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
|
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
|
||||||
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
|
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -188,7 +193,7 @@ impl DbAuthenticator {
|
|||||||
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
|
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CombinedAuthState {
|
pub struct CombinedAuthState {
|
||||||
/// In-memory tokens from GATEWAY_USER_TOKENS or GATEWAY_AUTH_TOKEN.
|
/// In-memory tokens from GATEWAY_AUTH_TOKEN.
|
||||||
pub env_auth: MultiAuthState,
|
pub env_auth: MultiAuthState,
|
||||||
/// DB-backed token authenticator (optional — only when a database is available).
|
/// DB-backed token authenticator (optional — only when a database is available).
|
||||||
pub db_auth: Option<DbAuthenticator>,
|
pub db_auth: Option<DbAuthenticator>,
|
||||||
|
|||||||
@@ -162,12 +162,7 @@ pub async fn invitations_accept_handler(
|
|||||||
return Err((StatusCode::GONE, "Invitation has expired".to_string()));
|
return Err((StatusCode::GONE, "Invitation has expired".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a user id from the display name.
|
let new_user_id = Uuid::new_v4().to_string();
|
||||||
let new_user_id = display_name
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.split_whitespace()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let user_record = UserRecord {
|
let user_record = UserRecord {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::channels::web::auth::AuthenticatedUser;
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
@@ -34,16 +35,7 @@ pub async fn users_create_handler(
|
|||||||
|
|
||||||
let email = body.get("email").and_then(|v| v.as_str()).map(String::from);
|
let email = body.get("email").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
|
||||||
// Generate user id: prefer email if provided, otherwise derive from display_name.
|
let user_id = Uuid::new_v4().to_string();
|
||||||
let user_id = if let Some(ref e) = email {
|
|
||||||
e.clone()
|
|
||||||
} else {
|
|
||||||
display_name
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.split_whitespace()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("-")
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let user_record = UserRecord {
|
let user_record = UserRecord {
|
||||||
|
|||||||
@@ -124,49 +124,6 @@ impl GatewayChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a gateway channel with a pre-built multi-user auth state.
|
|
||||||
pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
|
|
||||||
let auth = CombinedAuthState {
|
|
||||||
env_auth: auth,
|
|
||||||
db_auth: None,
|
|
||||||
};
|
|
||||||
let state = Arc::new(GatewayState {
|
|
||||||
msg_tx: tokio::sync::RwLock::new(None),
|
|
||||||
sse: Arc::new(SseManager::new()),
|
|
||||||
workspace: None,
|
|
||||||
workspace_pool: None,
|
|
||||||
session_manager: None,
|
|
||||||
log_broadcaster: None,
|
|
||||||
log_level_handle: None,
|
|
||||||
extension_manager: None,
|
|
||||||
tool_registry: None,
|
|
||||||
store: None,
|
|
||||||
job_manager: None,
|
|
||||||
prompt_queue: None,
|
|
||||||
scheduler: None,
|
|
||||||
default_user_id: config.user_id.clone(),
|
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
||||||
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
|
||||||
llm_provider: None,
|
|
||||||
skill_registry: None,
|
|
||||||
skill_catalog: None,
|
|
||||||
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
|
||||||
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
||||||
registry_entries: Vec::new(),
|
|
||||||
cost_guard: None,
|
|
||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
||||||
startup_time: std::time::Instant::now(),
|
|
||||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
|
||||||
active_config: server::ActiveConfigSnapshot::default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
state,
|
|
||||||
auth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to rebuild state, copying existing fields and applying a mutation.
|
/// Helper to rebuild state, copying existing fields and applying a mutation.
|
||||||
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
||||||
let mut new_state = GatewayState {
|
let mut new_state = GatewayState {
|
||||||
|
|||||||
@@ -417,6 +417,11 @@ pub async fn start_server(
|
|||||||
.route(
|
.route(
|
||||||
"/api/webhooks/u/{user_id}/{path}",
|
"/api/webhooks/u/{user_id}/{path}",
|
||||||
post(crate::channels::web::handlers::webhooks::webhook_trigger_user_scoped_handler),
|
post(crate::channels::web::handlers::webhooks::webhook_trigger_user_scoped_handler),
|
||||||
|
)
|
||||||
|
// Invitation accept (public — validated by invite token, not user auth)
|
||||||
|
.route(
|
||||||
|
"/api/invitations/accept",
|
||||||
|
post(super::handlers::invitations::invitations_accept_handler),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Protected routes (require auth)
|
// Protected routes (require auth)
|
||||||
@@ -545,10 +550,6 @@ pub async fn start_server(
|
|||||||
get(super::handlers::invitations::invitations_list_handler)
|
get(super::handlers::invitations::invitations_list_handler)
|
||||||
.post(super::handlers::invitations::invitations_create_handler),
|
.post(super::handlers::invitations::invitations_create_handler),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/invitations/accept",
|
|
||||||
post(super::handlers::invitations::invitations_accept_handler),
|
|
||||||
)
|
|
||||||
// Gateway control plane
|
// Gateway control plane
|
||||||
.route("/api/gateway/status", get(gateway_status_handler))
|
.route("/api/gateway/status", get(gateway_status_handler))
|
||||||
// OpenAI-compatible API
|
// OpenAI-compatible API
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
@@ -34,7 +34,8 @@ pub struct AgentConfig {
|
|||||||
/// Maximum tokens per job (0 = unlimited).
|
/// Maximum tokens per job (0 = unlimited).
|
||||||
pub max_tokens_per_job: u64,
|
pub max_tokens_per_job: u64,
|
||||||
/// Whether the deployment is multi-tenant (multiple users sharing one
|
/// Whether the deployment is multi-tenant (multiple users sharing one
|
||||||
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
|
/// instance). Detected at runtime after DB initialization, not from config.
|
||||||
|
/// See app.rs startup logic.
|
||||||
pub multi_tenant: bool,
|
pub multi_tenant: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +121,9 @@ impl AgentConfig {
|
|||||||
"AGENT_MAX_TOKENS_PER_JOB",
|
"AGENT_MAX_TOKENS_PER_JOB",
|
||||||
settings.agent.max_tokens_per_job,
|
settings.agent.max_tokens_per_job,
|
||||||
)?,
|
)?,
|
||||||
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
|
// Multi-tenant mode is detected at runtime after DB initialization,
|
||||||
// knob — multi-tenant mode is always implied by configuring user tokens.
|
// not from config. See app.rs startup logic.
|
||||||
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
multi_tenant: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-53
@@ -1,13 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use secrecy::SecretString;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
use secrecy::SecretString;
|
||||||
|
|
||||||
/// Channel configurations.
|
/// Channel configurations.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -54,18 +52,6 @@ pub struct GatewayConfig {
|
|||||||
pub workspace_read_scopes: Vec<String>,
|
pub workspace_read_scopes: Vec<String>,
|
||||||
/// Memory layer definitions (JSON in env var, or from external config).
|
/// Memory layer definitions (JSON in env var, or from external config).
|
||||||
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
|
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
|
||||||
/// Multi-user token map. When set, each token maps to a user identity.
|
|
||||||
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
|
|
||||||
/// to single-user mode via `auth_token` + `user_id`.
|
|
||||||
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-user token configuration for multi-user mode.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct UserTokenConfig {
|
|
||||||
pub user_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_read_scopes: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
||||||
@@ -196,41 +182,6 @@ impl ChannelsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
|
|
||||||
match optional_env("GATEWAY_USER_TOKENS")? {
|
|
||||||
Some(json_str) => {
|
|
||||||
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
|
|
||||||
&json_str,
|
|
||||||
)
|
|
||||||
.map_err(|e| ConfigError::InvalidValue {
|
|
||||||
key: "GATEWAY_USER_TOKENS".to_string(),
|
|
||||||
message: format!(
|
|
||||||
"must be valid JSON object mapping tokens to user configs: {e}"
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
if tokens.is_empty() {
|
|
||||||
return Err(ConfigError::InvalidValue {
|
|
||||||
key: "GATEWAY_USER_TOKENS".to_string(),
|
|
||||||
message:
|
|
||||||
"token map is empty — remove the variable to use single-user mode"
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (tok, cfg) in &tokens {
|
|
||||||
if cfg.user_id.trim().is_empty() {
|
|
||||||
return Err(ConfigError::InvalidValue {
|
|
||||||
key: "GATEWAY_USER_TOKENS".to_string(),
|
|
||||||
message: format!(
|
|
||||||
"token '{}...' has an empty user_id",
|
|
||||||
&tok[..tok.len().min(8)]
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(tokens)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
s.split(',')
|
s.split(',')
|
||||||
@@ -261,7 +212,6 @@ impl ChannelsConfig {
|
|||||||
user_id,
|
user_id,
|
||||||
workspace_read_scopes,
|
workspace_read_scopes,
|
||||||
memory_layers,
|
memory_layers,
|
||||||
user_tokens,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -419,7 +369,6 @@ mod tests {
|
|||||||
user_id: "default".to_string(),
|
user_id: "default".to_string(),
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
memory_layers: vec![],
|
memory_layers: vec![],
|
||||||
user_tokens: None,
|
|
||||||
};
|
};
|
||||||
assert_eq!(cfg.host, "127.0.0.1");
|
assert_eq!(cfg.host, "127.0.0.1");
|
||||||
assert_eq!(cfg.port, 3000);
|
assert_eq!(cfg.port, 3000);
|
||||||
@@ -436,7 +385,6 @@ mod tests {
|
|||||||
user_id: "anon".to_string(),
|
user_id: "anon".to_string(),
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
memory_layers: vec![],
|
memory_layers: vec![],
|
||||||
user_tokens: None,
|
|
||||||
};
|
};
|
||||||
assert!(cfg.auth_token.is_none());
|
assert!(cfg.auth_token.is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
|
|||||||
pub quiet_hours_end: Option<u32>,
|
pub quiet_hours_end: Option<u32>,
|
||||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
/// When true, cycle through all users with routines. Auto-detected from
|
/// When true, cycle through all users with routines. Set explicitly via
|
||||||
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
|
/// HEARTBEAT_MULTI_TENANT or detected at runtime after DB initialization.
|
||||||
pub multi_tenant: bool,
|
pub multi_tenant: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +105,7 @@ impl HeartbeatConfig {
|
|||||||
}
|
}
|
||||||
tz
|
tz
|
||||||
},
|
},
|
||||||
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
|
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?,
|
||||||
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
|
|
||||||
multi_tenant: parse_bool_env(
|
|
||||||
"HEARTBEAT_MULTI_TENANT",
|
|
||||||
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
|
||||||
)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-21
@@ -26,9 +26,13 @@ fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_api_token(row: &libsql::Row) -> ApiTokenRecord {
|
fn row_to_api_token(row: &libsql::Row) -> Result<ApiTokenRecord, DatabaseError> {
|
||||||
ApiTokenRecord {
|
let id_str = get_text(row, 0);
|
||||||
id: get_text(row, 0).parse().unwrap_or_default(),
|
let id: Uuid = id_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
|
||||||
|
Ok(ApiTokenRecord {
|
||||||
|
id,
|
||||||
user_id: get_text(row, 1),
|
user_id: get_text(row, 1),
|
||||||
name: get_text(row, 2),
|
name: get_text(row, 2),
|
||||||
token_prefix: get_text(row, 3),
|
token_prefix: get_text(row, 3),
|
||||||
@@ -36,12 +40,16 @@ fn row_to_api_token(row: &libsql::Row) -> ApiTokenRecord {
|
|||||||
last_used_at: get_opt_ts(row, 5),
|
last_used_at: get_opt_ts(row, 5),
|
||||||
created_at: get_ts(row, 6),
|
created_at: get_ts(row, 6),
|
||||||
revoked_at: get_opt_ts(row, 7),
|
revoked_at: get_opt_ts(row, 7),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_invitation(row: &libsql::Row) -> InvitationRecord {
|
fn row_to_invitation(row: &libsql::Row) -> Result<InvitationRecord, DatabaseError> {
|
||||||
InvitationRecord {
|
let id_str = get_text(row, 0);
|
||||||
id: get_text(row, 0).parse().unwrap_or_default(),
|
let id: Uuid = id_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
|
||||||
|
Ok(InvitationRecord {
|
||||||
|
id,
|
||||||
email: get_opt_text(row, 1),
|
email: get_opt_text(row, 1),
|
||||||
invited_by: get_text(row, 2),
|
invited_by: get_text(row, 2),
|
||||||
status: get_text(row, 3),
|
status: get_text(row, 3),
|
||||||
@@ -49,7 +57,7 @@ fn row_to_invitation(row: &libsql::Row) -> InvitationRecord {
|
|||||||
accepted_at: get_opt_ts(row, 5),
|
accepted_at: get_opt_ts(row, 5),
|
||||||
accepted_by: get_opt_text(row, 6),
|
accepted_by: get_opt_text(row, 6),
|
||||||
created_at: get_ts(row, 7),
|
created_at: get_ts(row, 7),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -274,7 +282,7 @@ impl UserStore for LibSqlBackend {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||||
{
|
{
|
||||||
tokens.push(row_to_api_token(&row));
|
tokens.push(row_to_api_token(&row)?);
|
||||||
}
|
}
|
||||||
Ok(tokens)
|
Ok(tokens)
|
||||||
}
|
}
|
||||||
@@ -328,8 +336,12 @@ impl UserStore for LibSqlBackend {
|
|||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||||
{
|
{
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
|
let id_str = get_text(&row, 0);
|
||||||
|
let token_id: Uuid = id_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
|
||||||
let token = ApiTokenRecord {
|
let token = ApiTokenRecord {
|
||||||
id: get_text(&row, 0).parse().unwrap_or_default(),
|
id: token_id,
|
||||||
user_id: get_text(&row, 1),
|
user_id: get_text(&row, 1),
|
||||||
name: get_text(&row, 2),
|
name: get_text(&row, 2),
|
||||||
token_prefix: get_text(&row, 3),
|
token_prefix: get_text(&row, 3),
|
||||||
@@ -410,7 +422,10 @@ impl UserStore for LibSqlBackend {
|
|||||||
.query(
|
.query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||||
FROM invitations WHERE invite_token_hash = ?1
|
FROM invitations
|
||||||
|
WHERE invite_token_hash = ?1
|
||||||
|
AND status = 'pending'
|
||||||
|
AND expires_at > strftime('%Y-%m-%dT%H:%M:%S', 'now')
|
||||||
"#,
|
"#,
|
||||||
params![libsql::Value::Blob(invite_hash.to_vec())],
|
params![libsql::Value::Blob(invite_hash.to_vec())],
|
||||||
)
|
)
|
||||||
@@ -422,7 +437,7 @@ impl UserStore for LibSqlBackend {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||||
{
|
{
|
||||||
Some(row) => Ok(Some(row_to_invitation(&row))),
|
Some(row) => Ok(Some(row_to_invitation(&row)?)),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,7 +493,7 @@ impl UserStore for LibSqlBackend {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||||
{
|
{
|
||||||
invitations.push(row_to_invitation(&row));
|
invitations.push(row_to_invitation(&row)?);
|
||||||
}
|
}
|
||||||
Ok(invitations)
|
Ok(invitations)
|
||||||
}
|
}
|
||||||
@@ -727,14 +742,21 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Verify accepted
|
// After acceptance, the invitation should no longer be found via hash
|
||||||
let accepted = db
|
// lookup (which filters for status='pending')
|
||||||
.get_invitation_by_hash(&invite_hash)
|
assert!(
|
||||||
.await
|
db.get_invitation_by_hash(&invite_hash)
|
||||||
.unwrap()
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
assert_eq!(accepted.status, "accepted");
|
.is_none(),
|
||||||
assert_eq!(accepted.accepted_by, Some("newuser".to_string()));
|
"Accepted invitation should not be returned by pending-only lookup"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify via list that it was accepted
|
||||||
|
let all = db.list_invitations(Some("alice")).await.unwrap();
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].status, "accepted");
|
||||||
|
assert_eq!(all[0].accepted_by, Some("newuser".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2314,7 +2314,7 @@ impl Store {
|
|||||||
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||||
let conn = self.conn().await?;
|
let conn = self.conn().await?;
|
||||||
let row = conn
|
let row = conn
|
||||||
.query_opt("SELECT * FROM users WHERE id = $1", &[&id])
|
.query_opt("SELECT id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|r| row_to_user(&r)))
|
Ok(row.map(|r| row_to_user(&r)))
|
||||||
}
|
}
|
||||||
@@ -2326,7 +2326,7 @@ impl Store {
|
|||||||
) -> Result<Option<UserRecord>, DatabaseError> {
|
) -> Result<Option<UserRecord>, DatabaseError> {
|
||||||
let conn = self.conn().await?;
|
let conn = self.conn().await?;
|
||||||
let row = conn
|
let row = conn
|
||||||
.query_opt("SELECT * FROM users WHERE email = $1", &[&email])
|
.query_opt("SELECT id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|r| row_to_user(&r)))
|
Ok(row.map(|r| row_to_user(&r)))
|
||||||
}
|
}
|
||||||
@@ -2337,13 +2337,13 @@ impl Store {
|
|||||||
let rows = match status {
|
let rows = match status {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
conn.query(
|
conn.query(
|
||||||
"SELECT * FROM users WHERE status = $1 ORDER BY created_at",
|
"SELECT id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
|
||||||
&[&s],
|
&[&s],
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
conn.query("SELECT * FROM users ORDER BY created_at", &[])
|
conn.query("SELECT id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-91
@@ -591,97 +591,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
let mut gateway_url: Option<String> = None;
|
let mut gateway_url: Option<String> = None;
|
||||||
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
||||||
if let Some(ref gw_config) = config.channels.gateway {
|
if let Some(ref gw_config) = config.channels.gateway {
|
||||||
// Migrate env-var users into DB on first run. If GATEWAY_USER_TOKENS is
|
let mut gw = GatewayChannel::new(gw_config.clone());
|
||||||
// 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};
|
|
||||||
let tokens = user_tokens
|
|
||||||
.iter()
|
|
||||||
.map(|(token, cfg)| {
|
|
||||||
(
|
|
||||||
token.clone(),
|
|
||||||
UserIdentity {
|
|
||||||
user_id: cfg.user_id.clone(),
|
|
||||||
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let auth = MultiAuthState::multi(tokens);
|
|
||||||
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
|
|
||||||
} else {
|
|
||||||
GatewayChannel::new(gw_config.clone())
|
|
||||||
};
|
|
||||||
gw = gw.with_llm_provider(Arc::clone(&components.llm));
|
gw = gw.with_llm_provider(Arc::clone(&components.llm));
|
||||||
if let Some(ref ws) = components.workspace {
|
if let Some(ref ws) = components.workspace {
|
||||||
gw = gw.with_workspace(Arc::clone(ws));
|
gw = gw.with_workspace(Arc::clone(ws));
|
||||||
|
|||||||
@@ -431,7 +431,6 @@ mod tests {
|
|||||||
user_id: "test".to_string(),
|
user_id: "test".to_string(),
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
memory_layers: Vec::new(),
|
memory_layers: Vec::new(),
|
||||||
user_tokens: None,
|
|
||||||
});
|
});
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
@@ -445,7 +444,6 @@ mod tests {
|
|||||||
user_id: "test".to_string(),
|
user_id: "test".to_string(),
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
memory_layers: Vec::new(),
|
memory_layers: Vec::new(),
|
||||||
user_tokens: None,
|
|
||||||
});
|
});
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user