diff --git a/migrations/V14__users.sql b/migrations/V14__users.sql index 1dbf284e..008cee39 100644 --- a/migrations/V14__users.sql +++ b/migrations/V14__users.sql @@ -8,6 +8,7 @@ CREATE TABLE users ( email TEXT UNIQUE, -- nullable for token-only users display_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', -- active | suspended | deactivated + role TEXT NOT NULL DEFAULT 'member', -- admin | member created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_login_at TIMESTAMPTZ, diff --git a/src/channels/web/auth.rs b/src/channels/web/auth.rs index e8d49cfa..988901f5 100644 --- a/src/channels/web/auth.rs +++ b/src/channels/web/auth.rs @@ -24,6 +24,8 @@ use crate::db::Database; #[derive(Debug, Clone)] pub struct UserIdentity { pub user_id: String, + /// `admin` or `member`. + pub role: String, /// Additional user scopes this identity can read from. pub workspace_read_scopes: Vec, } @@ -61,6 +63,7 @@ impl MultiAuthState { hash, UserIdentity { user_id, + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, )], @@ -163,7 +166,8 @@ impl DbAuthenticator { let identity = UserIdentity { user_id: user_record.id.clone(), - workspace_read_scopes: Vec::new(), // DB-backed users don't have static scopes yet + role: user_record.role.clone(), + workspace_read_scopes: Vec::new(), }; // Record token usage (best-effort, don't block auth) @@ -230,6 +234,31 @@ where } } +/// Axum extractor that requires the authenticated user to have the `admin` role. +/// +/// Use instead of `AuthenticatedUser` on endpoints that modify system-wide +/// state (user management, model selection, extension/skill installation). +pub struct AdminUser(pub UserIdentity); + +impl FromRequestParts for AdminUser +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let identity = parts + .extensions + .get::() + .cloned() + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?; + if identity.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Admin role required")); + } + Ok(AdminUser(identity)) + } +} + /// Whether query-string token auth is allowed for this request. /// /// Only GET requests to streaming endpoints may use `?token=xxx`. This @@ -346,6 +375,7 @@ mod tests { "tok-alice".to_string(), UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, ); @@ -353,6 +383,7 @@ mod tests { "tok-bob".to_string(), UserIdentity { user_id: "bob".to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, ); @@ -622,6 +653,7 @@ mod tests { "tok-alice".to_string(), UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["shared".to_string()], }, ); @@ -629,6 +661,7 @@ mod tests { "tok-bob".to_string(), UserIdentity { user_id: "bob".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()], }, ); diff --git a/src/channels/web/handlers/invitations.rs b/src/channels/web/handlers/invitations.rs index a1add834..bb92d2f3 100644 --- a/src/channels/web/handlers/invitations.rs +++ b/src/channels/web/handlers/invitations.rs @@ -8,14 +8,14 @@ use rand::rngs::OsRng; use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::channels::web::auth::AuthenticatedUser; +use crate::channels::web::auth::{AdminUser, AuthenticatedUser}; use crate::channels::web::server::GatewayState; use crate::db::{InvitationRecord, UserRecord}; -/// POST /api/invitations — create an invitation. +/// POST /api/invitations — create an invitation (admin only). pub async fn invitations_create_handler( State(state): State>, - AuthenticatedUser(user): AuthenticatedUser, + AdminUser(user): AdminUser, Json(body): Json, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( @@ -170,6 +170,7 @@ pub async fn invitations_accept_handler( email: invitation.email.clone(), display_name: display_name.clone(), status: "active".to_string(), + role: "member".to_string(), created_at: now, updated_at: now, last_login_at: None, diff --git a/src/channels/web/handlers/users.rs b/src/channels/web/handlers/users.rs index df8106de..177cd247 100644 --- a/src/channels/web/handlers/users.rs +++ b/src/channels/web/handlers/users.rs @@ -9,14 +9,14 @@ use axum::{ }; use uuid::Uuid; -use crate::channels::web::auth::AuthenticatedUser; +use crate::channels::web::auth::{AdminUser, AuthenticatedUser}; use crate::channels::web::server::GatewayState; use crate::db::UserRecord; /// POST /api/admin/users — create a new user. pub async fn users_create_handler( State(state): State>, - AuthenticatedUser(user): AuthenticatedUser, + AdminUser(user): AdminUser, Json(body): Json, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( @@ -34,6 +34,17 @@ pub async fn users_create_handler( .to_string(); let email = body.get("email").and_then(|v| v.as_str()).map(String::from); + let role = body + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("member") + .to_string(); + if role != "admin" && role != "member" { + return Err(( + StatusCode::BAD_REQUEST, + "role must be 'admin' or 'member'".to_string(), + )); + } let user_id = Uuid::new_v4().to_string(); @@ -43,6 +54,7 @@ pub async fn users_create_handler( email, display_name: display_name.clone(), status: "active".to_string(), + role, created_at: now, updated_at: now, last_login_at: None, @@ -60,6 +72,7 @@ pub async fn users_create_handler( "email": user_record.email, "display_name": user_record.display_name, "status": user_record.status, + "role": user_record.role, "created_at": user_record.created_at.to_rfc3339(), "created_by": user_record.created_by, }))) @@ -88,6 +101,7 @@ pub async fn users_list_handler( "email": u.email, "display_name": u.display_name, "status": u.status, + "role": u.role, "created_at": u.created_at.to_rfc3339(), "updated_at": u.updated_at.to_rfc3339(), "last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()), @@ -121,6 +135,7 @@ pub async fn users_detail_handler( "email": user_record.email, "display_name": user_record.display_name, "status": user_record.status, + "role": user_record.role, "created_at": user_record.created_at.to_rfc3339(), "updated_at": user_record.updated_at.to_rfc3339(), "last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()), @@ -172,6 +187,7 @@ pub async fn users_update_handler( "email": updated.email, "display_name": updated.display_name, "status": updated.status, + "role": updated.role, "created_at": updated.created_at.to_rfc3339(), "updated_at": updated.updated_at.to_rfc3339(), "metadata": updated.metadata, diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 08bc880d..6ca0d9ba 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -3102,6 +3102,7 @@ mod tests { // without needing the full auth middleware layer. req.extensions_mut().insert(UserIdentity { user_id: "test".to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }); @@ -3186,6 +3187,7 @@ mod tests { // without needing the full auth middleware layer. req.extensions_mut().insert(UserIdentity { user_id: "test".to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }); diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index 56ce03da..8bb39f63 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -33,6 +33,7 @@ fn two_user_auth() -> MultiAuthState { "tok-alice".to_string(), UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["shared".to_string()], }, ); @@ -40,6 +41,7 @@ fn two_user_auth() -> MultiAuthState { "tok-bob".to_string(), UserIdentity { user_id: "bob".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()], }, ); @@ -188,6 +190,7 @@ mod workspace_pool { ); let identity = UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec![], }; let ws = pool.get_or_create(&identity).await; @@ -216,6 +219,7 @@ mod workspace_pool { ); let identity = UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec![], }; let ws = pool.get_or_create(&identity).await; @@ -239,6 +243,7 @@ mod workspace_pool { ); let identity = UserIdentity { user_id: "bob".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()], }; let ws = pool.get_or_create(&identity).await; @@ -265,10 +270,12 @@ mod workspace_pool { ); let alice_id = UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec![], }; let bob_id = UserIdentity { user_id: "bob".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec![], }; @@ -300,6 +307,7 @@ mod workspace_pool { ); let identity = UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["token-scope".to_string()], }; let ws = pool.get_or_create(&identity).await; diff --git a/src/db/libsql/users.rs b/src/db/libsql/users.rs index d4e87885..e3b1f56f 100644 --- a/src/db/libsql/users.rs +++ b/src/db/libsql/users.rs @@ -10,7 +10,7 @@ use crate::db::libsql::LibSqlBackend; use crate::db::{ApiTokenRecord, DatabaseError, InvitationRecord, UserRecord, UserStore}; fn row_to_user(row: &libsql::Row) -> Result { - let metadata_str = get_text(row, 8); + let metadata_str = get_text(row, 9); let metadata: serde_json::Value = serde_json::from_str(&metadata_str) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; Ok(UserRecord { @@ -18,10 +18,11 @@ fn row_to_user(row: &libsql::Row) -> Result { email: get_opt_text(row, 1), display_name: get_text(row, 2), status: get_text(row, 3), - created_at: get_ts(row, 4), - updated_at: get_ts(row, 5), - last_login_at: get_opt_ts(row, 6), - created_by: get_opt_text(row, 7), + role: get_text(row, 4), + created_at: get_ts(row, 5), + updated_at: get_ts(row, 6), + last_login_at: get_opt_ts(row, 7), + created_by: get_opt_text(row, 8), metadata, }) } @@ -69,14 +70,15 @@ impl UserStore for LibSqlBackend { conn.execute( r#" - INSERT INTO users (id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) "#, params![ user.id.as_str(), opt_text(user.email.as_deref()), user.display_name.as_str(), user.status.as_str(), + user.role.as_str(), fmt_ts(&user.created_at), fmt_ts(&user.updated_at), fmt_opt_ts(&user.last_login_at), @@ -94,7 +96,7 @@ impl UserStore for LibSqlBackend { let mut rows = conn .query( r#" - SELECT id, email, display_name, status, created_at, updated_at, + SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = ?1 "#, @@ -118,7 +120,7 @@ impl UserStore for LibSqlBackend { let mut rows = conn .query( r#" - SELECT id, email, display_name, status, created_at, updated_at, + SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = ?1 "#, @@ -144,7 +146,7 @@ impl UserStore for LibSqlBackend { let mut rows = if let Some(status) = status { conn.query( r#" - SELECT id, email, display_name, status, created_at, updated_at, + SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = ?1 ORDER BY created_at DESC @@ -156,7 +158,7 @@ impl UserStore for LibSqlBackend { } else { conn.query( r#" - SELECT id, email, display_name, status, created_at, updated_at, + SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC @@ -316,7 +318,7 @@ impl UserStore for LibSqlBackend { SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at, - u.id, u.email, u.display_name, u.status, u.created_at, + u.id, u.email, u.display_name, u.status, u.role, u.created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata FROM api_tokens t JOIN users u ON u.id = t.user_id @@ -351,7 +353,7 @@ impl UserStore for LibSqlBackend { revoked_at: get_opt_ts(&row, 7), }; - let metadata_str = get_text(&row, 16); + let metadata_str = get_text(&row, 17); let metadata: serde_json::Value = serde_json::from_str(&metadata_str) .map_err(|e| DatabaseError::Serialization(e.to_string()))?; @@ -360,10 +362,11 @@ impl UserStore for LibSqlBackend { email: get_opt_text(&row, 9), display_name: get_text(&row, 10), status: get_text(&row, 11), - created_at: get_ts(&row, 12), - updated_at: get_ts(&row, 13), - last_login_at: get_opt_ts(&row, 14), - created_by: get_opt_text(&row, 15), + role: get_text(&row, 12), + created_at: get_ts(&row, 13), + updated_at: get_ts(&row, 14), + last_login_at: get_opt_ts(&row, 15), + created_by: get_opt_text(&row, 16), metadata, }; @@ -541,6 +544,7 @@ mod tests { email: Some(format!("{}@test.com", id)), display_name: id.to_string(), status: "active".to_string(), + role: "member".to_string(), created_at: Utc::now(), updated_at: Utc::now(), last_login_at: None, @@ -730,6 +734,7 @@ mod tests { email: Some("newuser@test.com".to_string()), display_name: "New User".to_string(), status: "active".to_string(), + role: "member".to_string(), created_at: Utc::now(), updated_at: Utc::now(), last_login_at: None, diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index bd662409..39d8867f 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -587,6 +587,7 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + role TEXT NOT NULL DEFAULT 'member', 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, @@ -775,6 +776,7 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + role TEXT NOT NULL DEFAULT 'member', 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, diff --git a/src/db/mod.rs b/src/db/mod.rs index 905ae70f..5aa94016 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -320,6 +320,8 @@ pub struct UserRecord { pub display_name: String, /// `active`, `suspended`, or `deactivated`. pub status: String, + /// `admin` or `member`. + pub role: String, pub created_at: DateTime, pub updated_at: DateTime, pub last_login_at: Option>, diff --git a/src/history/store.rs b/src/history/store.rs index 87d4e2ee..a9401921 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -2291,14 +2291,15 @@ impl Store { let conn = self.conn().await?; conn.execute( r#" - INSERT INTO users (id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) "#, &[ &user.id, &user.email, &user.display_name, &user.status, + &user.role, &user.created_at, &user.updated_at, &user.last_login_at, @@ -2314,7 +2315,7 @@ impl Store { pub async fn get_user(&self, id: &str) -> Result, DatabaseError> { let conn = self.conn().await?; let row = conn - .query_opt("SELECT id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id]) + .query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id]) .await?; Ok(row.map(|r| row_to_user(&r))) } @@ -2326,7 +2327,7 @@ impl Store { ) -> Result, DatabaseError> { let conn = self.conn().await?; let row = conn - .query_opt("SELECT id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email]) + .query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email]) .await?; Ok(row.map(|r| row_to_user(&r))) } @@ -2337,13 +2338,13 @@ impl Store { let rows = match status { Some(s) => { conn.query( - "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", + "SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC", &[&s], ) .await? } None => { - 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", &[]) + conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[]) .await? } }; @@ -2475,7 +2476,7 @@ impl Store { .query_opt( r#" SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at, - u.id as u_id, u.email, u.display_name, u.status, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata + u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata FROM api_tokens t JOIN users u ON t.user_id = u.id WHERE t.token_hash = $1 @@ -2502,6 +2503,7 @@ impl Store { email: r.get("email"), display_name: r.get("display_name"), status: r.get("status"), + role: r.get("role"), created_at: r.get("u_created_at"), updated_at: r.get("updated_at"), last_login_at: r.get("last_login_at"), @@ -2639,6 +2641,7 @@ fn row_to_user(row: &tokio_postgres::Row) -> UserRecord { email: row.get("email"), display_name: row.get("display_name"), status: row.get("status"), + role: row.get("role"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), last_login_at: row.get("last_login_at"), diff --git a/tests/multi_tenant_integration.rs b/tests/multi_tenant_integration.rs index 22c38511..375144b0 100644 --- a/tests/multi_tenant_integration.rs +++ b/tests/multi_tenant_integration.rs @@ -45,6 +45,7 @@ fn two_user_auth() -> MultiAuthState { ALICE_TOKEN.to_string(), UserIdentity { user_id: ALICE_USER_ID.to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, ); @@ -52,6 +53,7 @@ fn two_user_auth() -> MultiAuthState { BOB_TOKEN.to_string(), UserIdentity { user_id: BOB_USER_ID.to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["shared".to_string()], }, );