diff --git a/crates/server/src/handlers/admin/users.rs b/crates/server/src/handlers/admin/users.rs index bdda5f44..5a31d264 100644 --- a/crates/server/src/handlers/admin/users.rs +++ b/crates/server/src/handlers/admin/users.rs @@ -16,6 +16,7 @@ use think_watch_common::validation::{normalize_email, validate_email, validate_p use crate::app::AppState; use crate::middleware::auth_guard::{AuthUser, invalidate_user_perms}; +use crate::services::{role_repository, user_repository}; /// Parse a scope string into the `(scope_kind, scope_id)` tuple that /// `rbac_role_assignments` stores. Accepted shapes: @@ -123,26 +124,13 @@ pub async fn list_users( let (total, users): (i64, Vec) = match owned_teams { None => { - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM users \ - WHERE deleted_at IS NULL \ - AND ($1::text IS NULL OR email ILIKE $1 OR display_name ILIKE $1)", + user_repository::list( + &state.db, + search_pattern.as_deref(), + per_page as i64, + offset as i64, ) - .bind(search_pattern.as_deref()) - .fetch_one(&state.db) - .await?; - let users = sqlx::query_as::<_, User>( - "SELECT * FROM users \ - WHERE deleted_at IS NULL \ - AND ($1::text IS NULL OR email ILIKE $1 OR display_name ILIKE $1) \ - ORDER BY created_at DESC LIMIT $2 OFFSET $3", - ) - .bind(search_pattern.as_deref()) - .bind(per_page as i64) - .bind(offset as i64) - .fetch_all(&state.db) - .await?; - (total, users) + .await? } Some(team_ids) => { let team_ids_vec: Vec = team_ids.into_iter().collect(); @@ -150,46 +138,15 @@ pub async fn list_users( // they hold `users:read` for. The self inclusion makes // sure a team manager doesn't disappear from their own // user list. - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM users u \ - WHERE u.deleted_at IS NULL \ - AND ($3::text IS NULL OR u.email ILIKE $3 OR u.display_name ILIKE $3) \ - AND ( \ - u.id = $1 \ - OR EXISTS ( \ - SELECT 1 FROM team_members tm \ - WHERE tm.user_id = u.id \ - AND tm.team_id = ANY($2) \ - ) \ - )", + user_repository::list_in_teams( + &state.db, + auth_user.claims.sub, + &team_ids_vec, + search_pattern.as_deref(), + per_page as i64, + offset as i64, ) - .bind(auth_user.claims.sub) - .bind(&team_ids_vec) - .bind(search_pattern.as_deref()) - .fetch_one(&state.db) - .await?; - let users = sqlx::query_as::<_, User>( - "SELECT u.* FROM users u \ - WHERE u.deleted_at IS NULL \ - AND ($3::text IS NULL OR u.email ILIKE $3 OR u.display_name ILIKE $3) \ - AND ( \ - u.id = $1 \ - OR EXISTS ( \ - SELECT 1 FROM team_members tm \ - WHERE tm.user_id = u.id \ - AND tm.team_id = ANY($2) \ - ) \ - ) \ - ORDER BY u.created_at DESC LIMIT $4 OFFSET $5", - ) - .bind(auth_user.claims.sub) - .bind(&team_ids_vec) - .bind(search_pattern.as_deref()) - .bind(per_page as i64) - .bind(offset as i64) - .fetch_all(&state.db) - .await?; - (total, users) + .await? } }; @@ -210,25 +167,9 @@ pub async fn list_users( // Single query: every assignment for every user, joined against // `rbac_roles` so we can report system + custom uniformly. - type AssignmentRow = ( - uuid::Uuid, - uuid::Uuid, - String, - bool, - String, - Option, - ); - let rows: Vec = sqlx::query_as( - "SELECT ra.user_id, r.id, r.name, r.is_system, ra.scope_kind, ra.scope_id \ - FROM rbac_role_assignments ra \ - JOIN rbac_roles r ON r.id = ra.role_id \ - WHERE ra.user_id = ANY($1) \ - ORDER BY r.is_system DESC, r.name ASC", - ) - .bind(&user_ids) - .fetch_all(&state.db) - .await - .unwrap_or_default(); + let rows = user_repository::role_assignments_of(&state.db, &user_ids) + .await + .unwrap_or_default(); // Pre-size to the page so a 100-row page doesn't bounce through // multiple HashMap rehashes while we drain the join rows. @@ -256,18 +197,9 @@ pub async fn list_users( // looking at their merged-team list can tell engineering rows // from marketing rows). Joined with `teams` so we can return // the human name, not just the UUID. - type TeamRow = (uuid::Uuid, uuid::Uuid, String); - let team_rows: Vec = sqlx::query_as( - "SELECT tm.user_id, t.id, t.name \ - FROM team_members tm \ - JOIN teams t ON t.id = tm.team_id \ - WHERE tm.user_id = ANY($1) \ - ORDER BY t.name ASC", - ) - .bind(&user_ids) - .fetch_all(&state.db) - .await - .unwrap_or_default(); + let team_rows = user_repository::teams_of(&state.db, &user_ids) + .await + .unwrap_or_default(); let mut teams_map: std::collections::HashMap< uuid::Uuid, @@ -398,15 +330,12 @@ pub async fn list_super_admin_ids( /// inclusion in the response. The caller is responsible for any /// escalation checks (super_admin promotion, etc). async fn write_user_role_assignments( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::PgConnection, user_id: uuid::Uuid, assignments: &[RoleAssignmentRequest], assigned_by: uuid::Uuid, ) -> Result, AppError> { - sqlx::query("DELETE FROM rbac_role_assignments WHERE user_id = $1") - .bind(user_id) - .execute(&mut **tx) - .await?; + user_repository::delete_role_assignments(tx, user_id).await?; let mut out: Vec = Vec::with_capacity(assignments.len()); for a in assignments { @@ -414,23 +343,14 @@ async fn write_user_role_assignments( let (scope_kind, scope_id) = parse_scope(&raw_scope)?; // Insert + return role metadata in one round trip so we can // build the UserResponse without a second query. - let row: Option<(String, bool)> = sqlx::query_as( - "WITH ins AS (\ - INSERT INTO rbac_role_assignments \ - (user_id, role_id, scope_kind, scope_id, assigned_by) \ - VALUES ($1, $2, $3, $4, $5) \ - ON CONFLICT DO NOTHING \ - RETURNING role_id\ - ) \ - SELECT r.name, r.is_system FROM rbac_roles r \ - WHERE r.id = $2", + let row = user_repository::insert_role_assignment( + tx, + user_id, + a.role_id, + &scope_kind, + scope_id, + assigned_by, ) - .bind(user_id) - .bind(a.role_id) - .bind(&scope_kind) - .bind(scope_id) - .bind(assigned_by) - .fetch_optional(&mut **tx) .await .map_err(|e| match &e { sqlx::Error::Database(db) @@ -518,11 +438,7 @@ pub async fn create_user( let caller_has_admin = caller_has_super || caller_roles.iter().any(|r| r == "admin"); // Look up requested role names in one query to check privilege. let role_ids: Vec = req.role_assignments.iter().map(|a| a.role_id).collect(); - let requested: Vec<(String,)> = - sqlx::query_as("SELECT name FROM rbac_roles WHERE id = ANY($1)") - .bind(&role_ids) - .fetch_all(&state.db) - .await?; + let requested = role_repository::names_of(&state.db, &role_ids).await?; for (name,) in &requested { if name == "super_admin" && !caller_has_super { return Err(AppError::Forbidden( @@ -536,11 +452,7 @@ pub async fn create_user( } } - let exists = - sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") - .bind(&email) - .fetch_one(&state.db) - .await?; + let exists = user_repository::email_taken(&state.db, &email).await?; if exists { return Err(AppError::Conflict("Email already registered".into())); @@ -550,15 +462,13 @@ pub async fn create_user( let mut tx = state.db.begin().await?; - let user = sqlx::query_as::<_, User>( - r#"INSERT INTO users (email, display_name, password_hash, password_change_required) - VALUES ($1, $2, $3, $4) RETURNING *"#, + let user = user_repository::insert( + &mut tx, + &email, + &req.display_name, + &password_hash, + force_change, ) - .bind(&email) - .bind(&req.display_name) - .bind(&password_hash) - .bind(force_change) - .fetch_one(&mut *tx) .await?; let role_assignments = write_user_role_assignments( @@ -737,12 +647,7 @@ pub async fn update_user( )); } - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)", - ) - .bind(user_id) - .fetch_one(&state.db) - .await?; + let exists = user_repository::exists(&state.db, user_id).await?; if !exists { return Err(AppError::NotFound("User not found".into())); } @@ -762,11 +667,7 @@ pub async fn update_user( let caller_has_admin = caller_has_super || caller_roles.iter().any(|r| r == "admin"); let role_ids: Vec = assignments.iter().map(|a| a.role_id).collect(); - let requested: Vec<(String,)> = - sqlx::query_as("SELECT name FROM rbac_roles WHERE id = ANY($1)") - .bind(&role_ids) - .fetch_all(&state.db) - .await?; + let requested = role_repository::names_of(&state.db, &role_ids).await?; let requested_names: std::collections::HashSet<&String> = requested.iter().map(|(n,)| n).collect(); @@ -814,19 +715,11 @@ pub async fn update_user( if name.trim().is_empty() { return Err(AppError::BadRequest("Display name cannot be empty".into())); } - sqlx::query("UPDATE users SET display_name = $1, updated_at = now() WHERE id = $2") - .bind(name.trim()) - .bind(user_id) - .execute(&mut *tx) - .await?; + user_repository::set_display_name(&mut tx, user_id, name.trim()).await?; } if let Some(active) = req.is_active { - sqlx::query("UPDATE users SET is_active = $1, updated_at = now() WHERE id = $2") - .bind(active) - .bind(user_id) - .execute(&mut *tx) - .await?; + user_repository::set_active(&mut tx, user_id, active).await?; } if let Some(assignments) = authorized_role_assignments { @@ -867,14 +760,7 @@ pub async fn update_user( // "user deleted." Failure is logged but doesn't abort — // the gateway-side users-join (api_key_auth.rs) is the // ultimate guarantee. - if let Err(e) = sqlx::query( - "UPDATE api_keys \ - SET is_active = false, deleted_at = now(), disabled_reason = 'user_disabled' \ - WHERE user_id = $1 AND deleted_at IS NULL", - ) - .bind(user_id) - .execute(&state.db) - .await + if let Err(e) = user_repository::disable_api_keys_of_disabled_user(&state.db, user_id).await { tracing::warn!(%user_id, "failed to cascade api_keys disable on user deactivation: {e}"); } @@ -943,13 +829,7 @@ pub async fn delete_user( let mut tx = state.db.begin().await?; acquire_super_admin_guard_lock(&mut tx).await?; - let rows = sqlx::query( - "UPDATE users SET deleted_at = now(), is_active = false, updated_at = now() WHERE id = $1 AND deleted_at IS NULL", - ) - .bind(user_id) - .execute(&mut *tx) - .await? - .rows_affected(); + let rows = user_repository::soft_delete(&mut tx, user_id).await?; if rows == 0 { return Err(AppError::NotFound("User not found".into())); @@ -962,13 +842,7 @@ pub async fn delete_user( // letting them keep authenticating against the gateway. Pull it // into the TX so a failure rolls back the user delete too — both // succeed or neither does. - sqlx::query( - "UPDATE api_keys SET is_active = false, deleted_at = now(), disabled_reason = 'user_deleted' \ - WHERE user_id = $1 AND deleted_at IS NULL", - ) - .bind(user_id) - .execute(&mut *tx) - .await?; + user_repository::disable_api_keys_of_deleted_user(&mut tx, user_id).await?; // Validate the post-mutation invariant. If this delete took out the // last active super admin, we haven't committed yet — the Err short- // circuits and the tx rolls back on drop. @@ -1041,14 +915,14 @@ pub async fn reset_user_password( auth_user .assert_scope_for_user(&state.db, "users:update", user_id) .await?; - if !crate::services::user_repository::exists(&state.db, user_id).await? { + if !user_repository::exists(&state.db, user_id).await? { return Err(AppError::NotFound("User not found".into())); } let new_password = password::generate_random_password(); let hash = password::hash_password(&new_password)?; - crate::services::user_repository::update_password_hash(&state.db, user_id, &hash, true).await?; + user_repository::update_password_hash(&state.db, user_id, &hash, true).await?; // Invalidate signing public key to force re-login let _: () = diff --git a/crates/server/src/handlers/roles.rs b/crates/server/src/handlers/roles.rs index 7c18e932..5be21010 100644 --- a/crates/server/src/handlers/roles.rs +++ b/crates/server/src/handlers/roles.rs @@ -9,6 +9,7 @@ use think_watch_common::errors::AppError; use super::serde_util::deserialize_some; use crate::app::AppState; use crate::middleware::auth_guard::{AuthUser, invalidate_role_perms}; +use crate::services::role_repository::{self as repo, RoleRow}; /// Validate any Constraints blocks inside a policy_document's statements. fn validate_policy_constraints_in_doc(doc: &serde_json::Value) -> Result<(), AppError> { @@ -244,10 +245,7 @@ fn system_role_default_policy(name: &str) -> Option { /// are footguns that silently break authorization, so we want a loud /// fail-fast. pub async fn validate_seeded_roles(pool: &sqlx::PgPool) -> anyhow::Result<()> { - let rows: Vec<(String, serde_json::Value)> = - sqlx::query_as("SELECT name, policy_document FROM rbac_roles") - .fetch_all(pool) - .await?; + let rows = repo::policy_documents(pool).await?; let all_perm_keys: Vec<&str> = PERMISSIONS.iter().map(|p| p.key).collect(); let mut unknown: Vec = Vec::new(); for (role_name, doc) in &rows { @@ -312,26 +310,6 @@ pub struct RolesListResponse { pub items: Vec, } -/// One row from `rbac_roles` (with creator email LEFT JOINed in) -/// mapped 1:1 by sqlx. -type RoleRow = ( - Uuid, - String, - Option, - bool, - serde_json::Value, - Option, - chrono::DateTime, - chrono::DateTime, -); - -const ROLE_SELECT: &str = "SELECT r.id, r.name, r.description, r.is_system, \ - r.policy_document, \ - u.email AS created_by_email, \ - r.created_at, r.updated_at \ - FROM rbac_roles r \ - LEFT JOIN users u ON u.id = r.created_by"; - fn row_to_response(row: RoleRow, user_count: i64) -> RoleResponse { RoleResponse { id: row.0, @@ -367,22 +345,11 @@ pub async fn list_roles( .await?; // System rows first, then alphabetical. Permissions and counts are // pulled in two more queries (no N+1) and merged in Rust. - let rows: Vec = - sqlx::query_as(&format!("{ROLE_SELECT} ORDER BY is_system DESC, name ASC")) - .fetch_all(&state.db) - .await?; + let rows = repo::list(&state.db).await?; let role_ids: Vec = rows.iter().map(|r| r.0).collect(); - let counts: Vec<(Uuid, i64)> = sqlx::query_as( - "SELECT role_id, COUNT(*)::bigint \ - FROM rbac_role_assignments \ - WHERE role_id = ANY($1) \ - GROUP BY role_id", - ) - .bind(&role_ids) - .fetch_all(&state.db) - .await?; + let counts = repo::assignment_counts(&state.db, &role_ids).await?; let mut count_map: std::collections::HashMap = std::collections::HashMap::new(); for (rid, c) in counts { count_map.insert(rid, c); @@ -437,23 +404,13 @@ pub async fn create_role( rbac::validate_policy_document(&payload.policy_document).map_err(AppError::BadRequest)?; validate_policy_constraints_in_doc(&payload.policy_document)?; - let row: RoleRow = sqlx::query_as( - "WITH inserted AS ( \ - INSERT INTO rbac_roles (name, description, is_system, policy_document, created_by) \ - VALUES ($1, $2, FALSE, $3, $4) \ - RETURNING * \ - ) \ - SELECT i.id, i.name, i.description, i.is_system, i.policy_document, \ - u.email AS created_by_email, \ - i.created_at, i.updated_at \ - FROM inserted i \ - LEFT JOIN users u ON u.id = i.created_by", + let row: RoleRow = repo::insert( + &state.db, + name, + payload.description.as_deref(), + &payload.policy_document, + auth_user.claims.sub, ) - .bind(name) - .bind(&payload.description) - .bind(&payload.policy_document) - .bind(auth_user.claims.sub) - .fetch_one(&state.db) .await .map_err(|e| match &e { sqlx::Error::Database(db_err) if db_err.constraint() == Some("rbac_roles_name_key") => { @@ -515,12 +472,9 @@ pub async fn update_role( auth_user .require_global_permission(&state.db, "roles:update") .await?; - let existing = - sqlx::query_as::<_, (bool, String)>("SELECT is_system, name FROM rbac_roles WHERE id = $1") - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| AppError::NotFound("Role not found".into()))?; + let existing = repo::find_kind(&state.db, id) + .await? + .ok_or_else(|| AppError::NotFound("Role not found".into()))?; let is_system = existing.0; // System role gating: @@ -549,36 +503,19 @@ pub async fn update_role( None => (false, None), Some(inner) => (true, inner.as_deref()), }; - sqlx::query( - "UPDATE rbac_roles SET \ - name = COALESCE($2, name), \ - description = CASE WHEN $5 THEN $3 ELSE description END, \ - policy_document = COALESCE($4, policy_document), \ - updated_at = now() \ - WHERE id = $1", + repo::update( + &state.db, + id, + payload.name.as_deref().map(str::trim), + description_value, + payload.policy_document.as_ref(), + description_set, ) - .bind(id) - .bind(payload.name.as_deref().map(str::trim)) - .bind(description_value) - .bind(payload.policy_document.as_ref()) - .bind(description_set) - .execute(&state.db) .await?; - // Qualify with `r.id`: ROLE_SELECT joins `users u`, which also - // has an `id` column — an unqualified WHERE here used to bubble - // a 500 from "column reference \"id\" is ambiguous". - let row: RoleRow = sqlx::query_as(&format!("{ROLE_SELECT} WHERE r.id = $1")) - .bind(id) - .fetch_one(&state.db) - .await?; + let row = repo::get(&state.db, id).await?; - let user_count: i64 = - sqlx::query_scalar("SELECT COUNT(*)::bigint FROM rbac_role_assignments WHERE role_id = $1") - .bind(id) - .fetch_one(&state.db) - .await - .unwrap_or(0); + let user_count = repo::assignment_count(&state.db, id).await.unwrap_or(0); invalidate_role_perms(&state.db, &state.redis, id).await; @@ -627,12 +564,9 @@ pub async fn reset_role( .require_global_permission(&state.db, "roles:edit_system") .await?; - let existing = - sqlx::query_as::<_, (bool, String)>("SELECT is_system, name FROM rbac_roles WHERE id = $1") - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| AppError::NotFound("Role not found".into()))?; + let existing = repo::find_kind(&state.db, id) + .await? + .ok_or_else(|| AppError::NotFound("Role not found".into()))?; if !existing.0 { return Err(AppError::BadRequest( "Reset is only available for system roles".into(), @@ -646,30 +580,10 @@ pub async fn reset_role( )) })?; - sqlx::query( - "UPDATE rbac_roles SET \ - policy_document = $2, \ - updated_at = now() \ - WHERE id = $1", - ) - .bind(id) - .bind(&default_doc) - .execute(&state.db) - .await?; + repo::set_policy_document(&state.db, id, &default_doc).await?; - // Qualify with `r.id`: ROLE_SELECT joins `users u`, which also - // has an `id` column — an unqualified WHERE here used to bubble - // a 500 from "column reference \"id\" is ambiguous". - let row: RoleRow = sqlx::query_as(&format!("{ROLE_SELECT} WHERE r.id = $1")) - .bind(id) - .fetch_one(&state.db) - .await?; - let user_count: i64 = - sqlx::query_scalar("SELECT COUNT(*)::bigint FROM rbac_role_assignments WHERE role_id = $1") - .bind(id) - .fetch_one(&state.db) - .await - .unwrap_or(0); + let row = repo::get(&state.db, id).await?; + let user_count = repo::assignment_count(&state.db, id).await.unwrap_or(0); invalidate_role_perms(&state.db, &state.redis, id).await; @@ -716,23 +630,15 @@ pub async fn delete_role( auth_user .require_global_permission(&state.db, "roles:delete") .await?; - let existing = - sqlx::query_as::<_, (bool, String)>("SELECT is_system, name FROM rbac_roles WHERE id = $1") - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| AppError::NotFound("Role not found".into()))?; + let existing = repo::find_kind(&state.db, id) + .await? + .ok_or_else(|| AppError::NotFound("Role not found".into()))?; if existing.0 { return Err(AppError::BadRequest("Cannot delete system roles".into())); } let role_name = existing.1; - let assigned: i64 = - sqlx::query_scalar("SELECT COUNT(*)::bigint FROM rbac_role_assignments WHERE role_id = $1") - .bind(id) - .fetch_one(&state.db) - .await - .unwrap_or(0); + let assigned = repo::assignment_count(&state.db, id).await.unwrap_or(0); let mut tx = state.db.begin().await?; @@ -744,31 +650,13 @@ pub async fn delete_role( )); } Some(target_id) => { - let target_exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rbac_roles WHERE id = $1)") - .bind(target_id) - .fetch_one(&mut *tx) - .await?; + let target_exists = repo::exists_in(&mut tx, target_id).await?; if !target_exists { return Err(AppError::BadRequest("reassign_to role not found".into())); } // Migrate every (user, scope) pair to the new role. - sqlx::query( - "INSERT INTO rbac_role_assignments \ - (user_id, role_id, scope_kind, scope_id, assigned_by) \ - SELECT user_id, $2, scope_kind, scope_id, $3 \ - FROM rbac_role_assignments WHERE role_id = $1 \ - ON CONFLICT DO NOTHING", - ) - .bind(id) - .bind(target_id) - .bind(auth_user.claims.sub) - .execute(&mut *tx) - .await?; - sqlx::query("DELETE FROM rbac_role_assignments WHERE role_id = $1") - .bind(id) - .execute(&mut *tx) - .await?; + repo::copy_assignments(&mut tx, id, target_id, auth_user.claims.sub).await?; + repo::delete_assignments(&mut tx, id).await?; } None => { return Err(AppError::BadRequest(format!( @@ -778,10 +666,7 @@ pub async fn delete_role( } } - sqlx::query("DELETE FROM rbac_roles WHERE id = $1 AND is_system = FALSE") - .bind(id) - .execute(&mut *tx) - .await?; + repo::delete_custom(&mut tx, id).await?; tx.commit().await?; @@ -846,32 +731,12 @@ pub async fn list_role_members( auth_user .require_global_permission(&state.db, "roles:read") .await?; - let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rbac_roles WHERE id = $1)") - .bind(id) - .fetch_one(&state.db) - .await?; + let exists = repo::exists(&state.db, id).await?; if !exists { return Err(AppError::NotFound("Role not found".into())); } - type Row = ( - Uuid, - String, - Option, - String, - Option, - chrono::DateTime, - ); - let rows: Vec = sqlx::query_as( - "SELECT u.id, u.email, u.display_name, ra.scope_kind, ra.scope_id, ra.assigned_at \ - FROM rbac_role_assignments ra \ - JOIN users u ON u.id = ra.user_id \ - WHERE ra.role_id = $1 \ - ORDER BY u.email ASC", - ) - .bind(id) - .fetch_all(&state.db) - .await?; + let rows = repo::members(&state.db, id).await?; let items = rows .into_iter() @@ -979,10 +844,7 @@ pub async fn list_role_history( .await?; // 404 if the role doesn't exist — same shape as list_role_members. - let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rbac_roles WHERE id = $1)") - .bind(id) - .fetch_one(&state.db) - .await?; + let exists = repo::exists(&state.db, id).await?; if !exists { return Err(AppError::NotFound("Role not found".into())); } diff --git a/crates/server/src/handlers/teams.rs b/crates/server/src/handlers/teams.rs index f44e271f..a71fc387 100644 --- a/crates/server/src/handlers/teams.rs +++ b/crates/server/src/handlers/teams.rs @@ -29,7 +29,6 @@ use axum::Json; use axum::extract::{Path, State}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::FromRow; use uuid::Uuid; use think_watch_common::errors::AppError; @@ -37,14 +36,8 @@ use think_watch_common::errors::AppError; use super::serde_util::deserialize_some; use crate::app::AppState; use crate::middleware::auth_guard::{AuthUser, invalidate_team_perms, invalidate_user_perms}; - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow, utoipa::ToSchema)] -pub struct Team { - pub id: Uuid, - pub name: String, - pub description: Option, - pub created_at: DateTime, -} +use crate::services::team_repository::{self as repo, Team, TeamRoleRow, TeamWithCountRow}; +use crate::services::user_repository; #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TeamWithCount { @@ -96,14 +89,9 @@ async fn caller_is_team_member( caller_id: Uuid, team_id: Uuid, ) -> Result { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM team_members WHERE user_id = $1 AND team_id = $2)", - ) - .bind(caller_id) - .bind(team_id) - .fetch_one(pool) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!("team membership check failed: {e}")))?; + let exists = repo::is_member(pool, caller_id, team_id) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("team membership check failed: {e}")))?; Ok(exists) } @@ -160,58 +148,25 @@ pub async fn list_teams( .await?; let rows: Vec = match scope { - None => sqlx::query_as::<_, TeamWithCountRow>( - "SELECT t.id, t.name, t.description, t.created_at, \ - COALESCE(c.cnt, 0) AS member_count \ - FROM teams t \ - LEFT JOIN ( \ - SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id \ - ) c ON c.team_id = t.id \ - ORDER BY t.name ASC", - ) - .fetch_all(&state.db) - .await? - .into_iter() - .map(Into::into) - .collect(), - Some(scoped_team_ids) => { - // Convert the HashSet to a Vec for binding to ANY($2). - let scoped: Vec = scoped_team_ids.iter().copied().collect(); - sqlx::query_as::<_, TeamWithCountRow>( - "SELECT t.id, t.name, t.description, t.created_at, \ - COALESCE(c.cnt, 0) AS member_count \ - FROM teams t \ - LEFT JOIN ( \ - SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id \ - ) c ON c.team_id = t.id \ - WHERE EXISTS ( \ - SELECT 1 FROM team_members tm \ - WHERE tm.team_id = t.id AND tm.user_id = $1 \ - ) OR t.id = ANY($2) \ - ORDER BY t.name ASC", - ) - .bind(auth_user.claims.sub) - .bind(&scoped) - .fetch_all(&state.db) + None => repo::list(&state.db) .await? .into_iter() .map(Into::into) - .collect() + .collect(), + Some(scoped_team_ids) => { + // Convert the HashSet to a Vec for binding to ANY($2). + let scoped: Vec = scoped_team_ids.iter().copied().collect(); + repo::list_for_member_or_in(&state.db, auth_user.claims.sub, &scoped) + .await? + .into_iter() + .map(Into::into) + .collect() } }; Ok(Json(rows)) } -#[derive(FromRow)] -struct TeamWithCountRow { - id: Uuid, - name: String, - description: Option, - created_at: DateTime, - member_count: i64, -} - impl From for TeamWithCount { fn from(r: TeamWithCountRow) -> Self { TeamWithCount { @@ -253,19 +208,9 @@ pub async fn get_team( // renders the count card off this field. Returning a bare Team // here left the card showing undefined and any optimistic // decrement after a member removal flipping to NaN. - let row = sqlx::query_as::<_, TeamWithCountRow>( - "SELECT t.id, t.name, t.description, t.created_at, \ - COALESCE(c.cnt, 0) AS member_count \ - FROM teams t \ - LEFT JOIN (SELECT team_id, COUNT(*) AS cnt \ - FROM team_members GROUP BY team_id) c \ - ON c.team_id = t.id \ - WHERE t.id = $1", - ) - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| AppError::NotFound("Team not found".into()))?; + let row = repo::find_with_count(&state.db, id) + .await? + .ok_or_else(|| AppError::NotFound("Team not found".into()))?; Ok(Json(row.into())) } @@ -302,27 +247,21 @@ pub async fn create_team( if name.chars().count() > 255 { return Err(AppError::BadRequest("Team name too long".into())); } - let team = sqlx::query_as::<_, Team>( - "INSERT INTO teams (name, description) VALUES ($1, $2) \ - RETURNING id, name, description, created_at", - ) - .bind(name) - .bind(req.description.as_deref().map(str::trim)) - .fetch_one(&state.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - AppError::Conflict(format!("Team '{name}' already exists")) - } - // Delegate non-unique-violation errors to the global - // `From` mapping. Without `e.into()`, the - // catch-all `Internal(...)` here would swallow the - // `PoolTimedOut` / `PoolClosed` / `WorkerCrashed` / `Io` - // → `ServiceUnavailable(503)` distinction the global - // mapping makes, losing operator-facing infra-vs-app - // separation in dashboards. - other => other.into(), - })?; + let team = repo::insert(&state.db, name, req.description.as_deref().map(str::trim)) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { + AppError::Conflict(format!("Team '{name}' already exists")) + } + // Delegate non-unique-violation errors to the global + // `From` mapping. Without `e.into()`, the + // catch-all `Internal(...)` here would swallow the + // `PoolTimedOut` / `PoolClosed` / `WorkerCrashed` / `Io` + // → `ServiceUnavailable(503)` distinction the global + // mapping makes, losing operator-facing infra-vs-app + // separation in dashboards. + other => other.into(), + })?; state.audit.log( auth_user @@ -366,13 +305,9 @@ pub async fn update_team( .assert_scope_for_team(&state.db, "teams:update", id) .await?; - let existing = sqlx::query_as::<_, Team>( - "SELECT id, name, description, created_at FROM teams WHERE id = $1", - ) - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| AppError::NotFound("Team not found".into()))?; + let existing = repo::find(&state.db, id) + .await? + .ok_or_else(|| AppError::NotFound("Team not found".into()))?; // Distinguish absent (preserve current) from empty (reject). // The previous shape silently fell back to `existing.name` on @@ -406,23 +341,16 @@ pub async fn update_team( } }; - let updated = sqlx::query_as::<_, Team>( - "UPDATE teams SET name = $2, description = $3 WHERE id = $1 \ - RETURNING id, name, description, created_at", - ) - .bind(id) - .bind(new_name) - .bind(new_desc) - .fetch_one(&state.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - AppError::Conflict(format!("Team '{new_name}' already exists")) - } - // See create_team above — delegate to global mapping so - // transient-vs-permanent DB failures stay distinguishable. - other => other.into(), - })?; + let updated = repo::update(&state.db, id, new_name, new_desc) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { + AppError::Conflict(format!("Team '{new_name}' already exists")) + } + // See create_team above — delegate to global mapping so + // transient-vs-permanent DB failures stay distinguishable. + other => other.into(), + })?; state.audit.log( auth_user @@ -461,16 +389,10 @@ pub async fn delete_team( .require_global_permission(&state.db, "teams:delete") .await?; - let name: Option = sqlx::query_scalar("SELECT name FROM teams WHERE id = $1") - .bind(id) - .fetch_optional(&state.db) - .await?; + let name = repo::name_of(&state.db, id).await?; let name = name.ok_or_else(|| AppError::NotFound("Team not found".into()))?; - sqlx::query("DELETE FROM teams WHERE id = $1") - .bind(id) - .execute(&state.db) - .await?; + repo::delete(&state.db, id).await?; state.audit.log( auth_user @@ -517,18 +439,7 @@ pub async fn list_members( .await?; } - type Row = (Uuid, String, String, DateTime); - let rows: Vec = sqlx::query_as( - "SELECT u.id, u.email, u.display_name, tm.joined_at \ - FROM team_members tm \ - JOIN users u ON u.id = tm.user_id \ - WHERE tm.team_id = $1 \ - AND u.deleted_at IS NULL \ - ORDER BY tm.joined_at ASC", - ) - .bind(team_id) - .fetch_all(&state.db) - .await?; + let rows = repo::members(&state.db, team_id).await?; Ok(Json( rows.into_iter() @@ -571,12 +482,7 @@ pub async fn add_member( .await?; // Validate the user actually exists, is active, and isn't soft-deleted. - let user_exists: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_active = true AND deleted_at IS NULL)", - ) - .bind(req.user_id) - .fetch_one(&state.db) - .await?; + let user_exists = user_repository::active_exists(&state.db, req.user_id).await?; if !user_exists { return Err(AppError::NotFound("User not found".into())); } @@ -592,28 +498,13 @@ pub async fn add_member( // // ON CONFLICT DO NOTHING handles re-adding an existing member // idempotently (0 rows affected but not an error). - let result = sqlx::query( - r#"INSERT INTO team_members (user_id, team_id) - SELECT $1, $2 - WHERE (SELECT COUNT(*) FROM team_members WHERE user_id = $1) < $3 - ON CONFLICT (user_id, team_id) DO NOTHING"#, - ) - .bind(req.user_id) - .bind(team_id) - .bind(MAX_TEAMS_PER_USER) - .execute(&state.db) - .await?; + let inserted = + repo::add_member_capped(&state.db, req.user_id, team_id, MAX_TEAMS_PER_USER).await?; // 0 rows can mean "already a member" (fine) OR "at limit" (error). // Disambiguate with a follow-up check so we return the right message. - if result.rows_affected() == 0 { - let already_member: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM team_members WHERE user_id = $1 AND team_id = $2)", - ) - .bind(req.user_id) - .bind(team_id) - .fetch_one(&state.db) - .await?; + if inserted == 0 { + let already_member = repo::is_member(&state.db, req.user_id, team_id).await?; if !already_member { return Err(AppError::BadRequest(format!( "User already belongs to {MAX_TEAMS_PER_USER} teams (maximum)" @@ -660,12 +551,7 @@ pub async fn remove_member( .assert_scope_for_team(&state.db, "team_members:write", team_id) .await?; - let removed = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") - .bind(team_id) - .bind(user_id) - .execute(&state.db) - .await? - .rows_affected(); + let removed = repo::remove_member(&state.db, team_id, user_id).await?; if removed == 0 { return Err(AppError::NotFound("Member not found".into())); @@ -689,14 +575,6 @@ pub async fn remove_member( // members. This turns teams into permission groups. // --------------------------------------------------------------------------- -#[derive(Debug, serde::Serialize, sqlx::FromRow)] -pub struct TeamRoleRow { - pub role_id: Uuid, - pub name: String, - pub is_system: bool, - pub assigned_at: chrono::DateTime, -} - #[utoipa::path( get, path = "/api/admin/teams/{id}/roles", @@ -720,16 +598,7 @@ pub async fn list_team_roles( .assert_scope_for_team(&state.db, "teams:read", team_id) .await?; - let rows: Vec = sqlx::query_as::<_, TeamRoleRow>( - "SELECT tra.role_id, r.name, r.is_system, tra.assigned_at \ - FROM team_role_assignments tra \ - JOIN rbac_roles r ON r.id = tra.role_id \ - WHERE tra.team_id = $1 \ - ORDER BY r.is_system DESC, r.name ASC", - ) - .bind(team_id) - .fetch_all(&state.db) - .await?; + let rows = repo::roles(&state.db, team_id).await?; Ok(Json(rows)) } @@ -764,16 +633,7 @@ pub async fn assign_team_role( .assert_scope_for_team(&state.db, "teams:update", team_id) .await?; - sqlx::query( - "INSERT INTO team_role_assignments (team_id, role_id, assigned_by) \ - VALUES ($1, $2, $3) \ - ON CONFLICT (team_id, role_id) DO NOTHING", - ) - .bind(team_id) - .bind(req.role_id) - .bind(auth_user.claims.sub) - .execute(&state.db) - .await?; + repo::assign_role(&state.db, team_id, req.role_id, auth_user.claims.sub).await?; invalidate_team_perms(&state.db, &state.redis, team_id).await; @@ -813,11 +673,7 @@ pub async fn remove_team_role( .assert_scope_for_team(&state.db, "teams:update", team_id) .await?; - sqlx::query("DELETE FROM team_role_assignments WHERE team_id = $1 AND role_id = $2") - .bind(team_id) - .bind(role_id) - .execute(&state.db) - .await?; + repo::remove_role(&state.db, team_id, role_id).await?; invalidate_team_perms(&state.db, &state.redis, team_id).await; diff --git a/crates/server/src/openapi.rs b/crates/server/src/openapi.rs index 7768cb6b..1c1937c4 100644 --- a/crates/server/src/openapi.rs +++ b/crates/server/src/openapi.rs @@ -38,15 +38,14 @@ use crate::handlers::{ RoleResponse, RolesListResponse, UpdateRoleRequest, }, setup::{AdminSetup, SetupInitRequest, SetupInitResponse, SetupStatusResponse}, - teams::{ - AddMemberRequest, CreateTeamRequest, Team, TeamMemberRow, TeamWithCount, UpdateTeamRequest, - }, + teams::{AddMemberRequest, CreateTeamRequest, TeamMemberRow, TeamWithCount, UpdateTeamRequest}, user_limits::{ EffectiveCap, EffectiveRule, LimitsAuditEvent, LimitsDashboard, ResetCounterRequest, ResetCounterResponse, UsageDay, }, }; use crate::services::model_repository::ModelRow; +use crate::services::team_repository::Team; /// OpenAPI document covering the ThinkWatch console API (port 3001). /// diff --git a/crates/server/src/services/mod.rs b/crates/server/src/services/mod.rs index cd49e0db..8cb1e0a7 100644 --- a/crates/server/src/services/mod.rs +++ b/crates/server/src/services/mod.rs @@ -35,7 +35,9 @@ pub mod pricing_repository; pub mod provider_repository; pub mod rbac_service; pub mod refresh_blacklist; +pub mod role_repository; pub mod session_service; +pub mod team_repository; pub mod totp_service; pub mod user_repository; pub mod webhook_outbox_repository; diff --git a/crates/server/src/services/role_repository.rs b/crates/server/src/services/role_repository.rs new file mode 100644 index 00000000..d7dee25c --- /dev/null +++ b/crates/server/src/services/role_repository.rs @@ -0,0 +1,265 @@ +//! Role repository — the `rbac_roles` catalog and the role side of +//! `rbac_role_assignments`. +//! +//! Thin wrappers over sqlx, one statement per function; policy +//! validation, system-role gating, audit and permission-cache +//! invalidation stay in `handlers::roles`. The statements `delete_role` +//! runs in one transaction take a `&mut PgConnection`. + +use sqlx::{PgConnection, PgPool}; +use think_watch_common::errors::AppError; +use uuid::Uuid; + +/// One row from `rbac_roles` (with creator email LEFT JOINed in): +/// (id, name, description, is_system, policy_document, created_by_email, +/// created_at, updated_at). +pub type RoleRow = ( + Uuid, + String, + Option, + bool, + serde_json::Value, + Option, + chrono::DateTime, + chrono::DateTime, +); + +/// One member of a role: (user id, email, display name, scope_kind, +/// scope_id, assigned_at). +pub type RoleMemberRow = ( + Uuid, + String, + Option, + String, + Option, + chrono::DateTime, +); + +const ROLE_SELECT: &str = "SELECT r.id, r.name, r.description, r.is_system, \ + r.policy_document, \ + u.email AS created_by_email, \ + r.created_at, r.updated_at \ + FROM rbac_roles r \ + LEFT JOIN users u ON u.id = r.created_by"; + +/// Every role's (name, policy_document), for the startup catalog check. +pub async fn policy_documents( + pool: &PgPool, +) -> Result, sqlx::Error> { + sqlx::query_as("SELECT name, policy_document FROM rbac_roles") + .fetch_all(pool) + .await +} + +/// Every role, system rows first, then alphabetical. +pub async fn list(pool: &PgPool) -> Result, AppError> { + Ok( + sqlx::query_as(&format!("{ROLE_SELECT} ORDER BY is_system DESC, name ASC")) + .fetch_all(pool) + .await?, + ) +} + +pub async fn get(pool: &PgPool, id: Uuid) -> Result { + // Qualify with `r.id`: ROLE_SELECT joins `users u`, which also + // has an `id` column — an unqualified WHERE here used to bubble + // a 500 from "column reference \"id\" is ambiguous". + Ok(sqlx::query_as(&format!("{ROLE_SELECT} WHERE r.id = $1")) + .bind(id) + .fetch_one(pool) + .await?) +} + +/// A role's (is_system, name). +pub async fn find_kind(pool: &PgPool, id: Uuid) -> Result, AppError> { + Ok( + sqlx::query_as::<_, (bool, String)>("SELECT is_system, name FROM rbac_roles WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?, + ) +} + +pub async fn exists(pool: &PgPool, id: Uuid) -> Result { + Ok( + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rbac_roles WHERE id = $1)") + .bind(id) + .fetch_one(pool) + .await?, + ) +} + +/// [`exists`], inside the caller's transaction. +pub async fn exists_in(conn: &mut PgConnection, id: Uuid) -> Result { + Ok( + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rbac_roles WHERE id = $1)") + .bind(id) + .fetch_one(conn) + .await?, + ) +} + +/// The names of whichever of `ids` exist. +pub async fn names_of(pool: &PgPool, ids: &[Uuid]) -> Result, AppError> { + Ok( + sqlx::query_as("SELECT name FROM rbac_roles WHERE id = ANY($1)") + .bind(ids) + .fetch_all(pool) + .await?, + ) +} + +/// Create a custom role. The raw error comes back so the caller can +/// report a taken name. +pub async fn insert( + pool: &PgPool, + name: &str, + description: Option<&str>, + policy_document: &serde_json::Value, + created_by: Uuid, +) -> Result { + sqlx::query_as( + "WITH inserted AS ( \ + INSERT INTO rbac_roles (name, description, is_system, policy_document, created_by) \ + VALUES ($1, $2, FALSE, $3, $4) \ + RETURNING * \ + ) \ + SELECT i.id, i.name, i.description, i.is_system, i.policy_document, \ + u.email AS created_by_email, \ + i.created_at, i.updated_at \ + FROM inserted i \ + LEFT JOIN users u ON u.id = i.created_by", + ) + .bind(name) + .bind(description) + .bind(policy_document) + .bind(created_by) + .fetch_one(pool) + .await +} + +/// PATCH a role: `None` name / policy keeps the column; the description +/// is replaced (possibly with NULL) only when `description_set`. +pub async fn update( + pool: &PgPool, + id: Uuid, + name: Option<&str>, + description: Option<&str>, + policy_document: Option<&serde_json::Value>, + description_set: bool, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE rbac_roles SET \ + name = COALESCE($2, name), \ + description = CASE WHEN $5 THEN $3 ELSE description END, \ + policy_document = COALESCE($4, policy_document), \ + updated_at = now() \ + WHERE id = $1", + ) + .bind(id) + .bind(name) + .bind(description) + .bind(policy_document) + .bind(description_set) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn set_policy_document( + pool: &PgPool, + id: Uuid, + policy_document: &serde_json::Value, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE rbac_roles SET \ + policy_document = $2, \ + updated_at = now() \ + WHERE id = $1", + ) + .bind(id) + .bind(policy_document) + .execute(pool) + .await?; + Ok(()) +} + +/// Delete a custom role (system rows are never touched). +pub async fn delete_custom(conn: &mut PgConnection, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM rbac_roles WHERE id = $1 AND is_system = FALSE") + .bind(id) + .execute(conn) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// rbac_role_assignments +// --------------------------------------------------------------------------- + +/// (role id, assignment count) for whichever of `ids` have assignments. +pub async fn assignment_counts(pool: &PgPool, ids: &[Uuid]) -> Result, AppError> { + Ok(sqlx::query_as( + "SELECT role_id, COUNT(*)::bigint \ + FROM rbac_role_assignments \ + WHERE role_id = ANY($1) \ + GROUP BY role_id", + ) + .bind(ids) + .fetch_all(pool) + .await?) +} + +/// How many assignments a role has. The raw error comes back: callers +/// fall back to 0. +pub async fn assignment_count(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_scalar("SELECT COUNT(*)::bigint FROM rbac_role_assignments WHERE role_id = $1") + .bind(id) + .fetch_one(pool) + .await +} + +/// A role's members, by email. +pub async fn members(pool: &PgPool, id: Uuid) -> Result, AppError> { + Ok(sqlx::query_as( + "SELECT u.id, u.email, u.display_name, ra.scope_kind, ra.scope_id, ra.assigned_at \ + FROM rbac_role_assignments ra \ + JOIN users u ON u.id = ra.user_id \ + WHERE ra.role_id = $1 \ + ORDER BY u.email ASC", + ) + .bind(id) + .fetch_all(pool) + .await?) +} + +/// Copy every (user, scope) assignment of role `from` to role `to`, +/// recording `assigned_by`; pairs `to` already has are skipped. +pub async fn copy_assignments( + conn: &mut PgConnection, + from: Uuid, + to: Uuid, + assigned_by: Uuid, +) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO rbac_role_assignments \ + (user_id, role_id, scope_kind, scope_id, assigned_by) \ + SELECT user_id, $2, scope_kind, scope_id, $3 \ + FROM rbac_role_assignments WHERE role_id = $1 \ + ON CONFLICT DO NOTHING", + ) + .bind(from) + .bind(to) + .bind(assigned_by) + .execute(conn) + .await?; + Ok(()) +} + +pub async fn delete_assignments(conn: &mut PgConnection, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM rbac_role_assignments WHERE role_id = $1") + .bind(id) + .execute(conn) + .await?; + Ok(()) +} diff --git a/crates/server/src/services/team_repository.rs b/crates/server/src/services/team_repository.rs new file mode 100644 index 00000000..58722705 --- /dev/null +++ b/crates/server/src/services/team_repository.rs @@ -0,0 +1,280 @@ +//! Team repository — the `teams` catalog, `team_members` and +//! `team_role_assignments`. +//! +//! Thin wrappers over sqlx, one statement per function; permission +//! checks, name validation, audit and permission-cache invalidation stay +//! in `handlers::teams`. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use think_watch_common::errors::AppError; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, utoipa::ToSchema)] +pub struct Team { + pub id: Uuid, + pub name: String, + pub description: Option, + pub created_at: DateTime, +} + +/// A team with its member count joined in. +#[derive(FromRow)] +pub struct TeamWithCountRow { + pub id: Uuid, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub member_count: i64, +} + +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct TeamRoleRow { + pub role_id: Uuid, + pub name: String, + pub is_system: bool, + pub assigned_at: chrono::DateTime, +} + +/// One roster entry: (user id, email, display name, joined_at). +pub type TeamMemberTuple = (Uuid, String, String, DateTime); + +// --------------------------------------------------------------------------- +// teams +// --------------------------------------------------------------------------- + +/// Every team, by name. +pub async fn list(pool: &PgPool) -> Result, AppError> { + Ok(sqlx::query_as::<_, TeamWithCountRow>( + "SELECT t.id, t.name, t.description, t.created_at, \ + COALESCE(c.cnt, 0) AS member_count \ + FROM teams t \ + LEFT JOIN ( \ + SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id \ + ) c ON c.team_id = t.id \ + ORDER BY t.name ASC", + ) + .fetch_all(pool) + .await?) +} + +/// The teams `user_id` belongs to, plus `team_ids`, by name. +pub async fn list_for_member_or_in( + pool: &PgPool, + user_id: Uuid, + team_ids: &[Uuid], +) -> Result, AppError> { + Ok(sqlx::query_as::<_, TeamWithCountRow>( + "SELECT t.id, t.name, t.description, t.created_at, \ + COALESCE(c.cnt, 0) AS member_count \ + FROM teams t \ + LEFT JOIN ( \ + SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id \ + ) c ON c.team_id = t.id \ + WHERE EXISTS ( \ + SELECT 1 FROM team_members tm \ + WHERE tm.team_id = t.id AND tm.user_id = $1 \ + ) OR t.id = ANY($2) \ + ORDER BY t.name ASC", + ) + .bind(user_id) + .bind(team_ids) + .fetch_all(pool) + .await?) +} + +pub async fn find_with_count( + pool: &PgPool, + id: Uuid, +) -> Result, AppError> { + Ok(sqlx::query_as::<_, TeamWithCountRow>( + "SELECT t.id, t.name, t.description, t.created_at, \ + COALESCE(c.cnt, 0) AS member_count \ + FROM teams t \ + LEFT JOIN (SELECT team_id, COUNT(*) AS cnt \ + FROM team_members GROUP BY team_id) c \ + ON c.team_id = t.id \ + WHERE t.id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await?) +} + +pub async fn find(pool: &PgPool, id: Uuid) -> Result, AppError> { + Ok(sqlx::query_as::<_, Team>( + "SELECT id, name, description, created_at FROM teams WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await?) +} + +pub async fn name_of(pool: &PgPool, id: Uuid) -> Result, AppError> { + Ok(sqlx::query_scalar("SELECT name FROM teams WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?) +} + +/// Create a team. The raw error comes back so the caller can report a +/// taken name. +pub async fn insert( + pool: &PgPool, + name: &str, + description: Option<&str>, +) -> Result { + sqlx::query_as::<_, Team>( + "INSERT INTO teams (name, description) VALUES ($1, $2) \ + RETURNING id, name, description, created_at", + ) + .bind(name) + .bind(description) + .fetch_one(pool) + .await +} + +/// Rename / re-describe a team. The raw error comes back so the caller +/// can report a taken name. +pub async fn update( + pool: &PgPool, + id: Uuid, + name: &str, + description: Option, +) -> Result { + sqlx::query_as::<_, Team>( + "UPDATE teams SET name = $2, description = $3 WHERE id = $1 \ + RETURNING id, name, description, created_at", + ) + .bind(id) + .bind(name) + .bind(description) + .fetch_one(pool) + .await +} + +/// Delete a team; memberships and team role assignments cascade. +pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM teams WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// team_members +// --------------------------------------------------------------------------- + +/// Is `user_id` a member of `team_id`? The raw error comes back so the +/// membership gate can say which check failed. +pub async fn is_member(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> Result { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM team_members WHERE user_id = $1 AND team_id = $2)", + ) + .bind(user_id) + .bind(team_id) + .fetch_one(pool) + .await +} + +/// A team's live members, in join order. +pub async fn members(pool: &PgPool, team_id: Uuid) -> Result, AppError> { + Ok(sqlx::query_as( + "SELECT u.id, u.email, u.display_name, tm.joined_at \ + FROM team_members tm \ + JOIN users u ON u.id = tm.user_id \ + WHERE tm.team_id = $1 \ + AND u.deleted_at IS NULL \ + ORDER BY tm.joined_at ASC", + ) + .bind(team_id) + .fetch_all(pool) + .await?) +} + +/// Add a member, but only while the user belongs to fewer than +/// `max_teams` teams; an existing membership is left alone. Returns the +/// rows inserted (0 = already a member, or at the cap). +/// +/// The cap check and the insert are one statement so two concurrent +/// adds can't both slip past the limit. +pub async fn add_member_capped( + pool: &PgPool, + user_id: Uuid, + team_id: Uuid, + max_teams: i64, +) -> Result { + Ok(sqlx::query( + r#"INSERT INTO team_members (user_id, team_id) + SELECT $1, $2 + WHERE (SELECT COUNT(*) FROM team_members WHERE user_id = $1) < $3 + ON CONFLICT (user_id, team_id) DO NOTHING"#, + ) + .bind(user_id) + .bind(team_id) + .bind(max_teams) + .execute(pool) + .await? + .rows_affected()) +} + +/// Returns the rows deleted. +pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> Result { + Ok( + sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") + .bind(team_id) + .bind(user_id) + .execute(pool) + .await? + .rows_affected(), + ) +} + +// --------------------------------------------------------------------------- +// team_role_assignments +// --------------------------------------------------------------------------- + +/// A team's roles, system roles first, then by name. +pub async fn roles(pool: &PgPool, team_id: Uuid) -> Result, AppError> { + Ok(sqlx::query_as::<_, TeamRoleRow>( + "SELECT tra.role_id, r.name, r.is_system, tra.assigned_at \ + FROM team_role_assignments tra \ + JOIN rbac_roles r ON r.id = tra.role_id \ + WHERE tra.team_id = $1 \ + ORDER BY r.is_system DESC, r.name ASC", + ) + .bind(team_id) + .fetch_all(pool) + .await?) +} + +/// Assign a role to a team; an existing assignment is left alone. +pub async fn assign_role( + pool: &PgPool, + team_id: Uuid, + role_id: Uuid, + assigned_by: Uuid, +) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO team_role_assignments (team_id, role_id, assigned_by) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (team_id, role_id) DO NOTHING", + ) + .bind(team_id) + .bind(role_id) + .bind(assigned_by) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn remove_role(pool: &PgPool, team_id: Uuid, role_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM team_role_assignments WHERE team_id = $1 AND role_id = $2") + .bind(team_id) + .bind(role_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/server/src/services/user_repository.rs b/crates/server/src/services/user_repository.rs index fdb3993a..459df91e 100644 --- a/crates/server/src/services/user_repository.rs +++ b/crates/server/src/services/user_repository.rs @@ -1,26 +1,168 @@ -#![allow(dead_code)] -// landing zone: some helpers are staged here -// ahead of callers migrating off inline SQL. Remove the allow once -// every function has at least one caller. - -//! User repository — thin wrappers over the `users` table so handlers -//! don't carry raw SQL. +//! User repository — the `users` table, a user's rows in +//! `rbac_role_assignments`, and the `api_keys` cascades that follow a +//! user being disabled or deleted. //! -//! Extracted out of `handlers::admin` as the first landing zone for the -//! service-layer migration. Every function is a single atomic SQL call -//! that matches an existing handler's `sqlx::query`; no business rules -//! live here (those go into a `UserService` once we need to combine -//! multiple repositories). The repository deliberately takes `&PgPool` -//! OR a `&mut Transaction` per call — mutations that must coexist with -//! a super-admin quorum check belong inside the caller's transaction. - -use chrono::{DateTime, Utc}; +//! Thin wrappers over sqlx, one statement per function; permission +//! checks, the super-admin quorum guard, audit and cache invalidation +//! stay in `handlers::admin::users`. Statements that must share the +//! caller's transaction (the quorum guard lock, role replacement) take +//! a `&mut PgConnection`. + +use sqlx::{PgConnection, PgPool}; use think_watch_common::errors::AppError; use think_watch_common::models::User; use uuid::Uuid; +/// One role assignment of a listed user: (user id, role id, role name, +/// is_system, scope_kind, scope_id). +pub type UserAssignmentRow = (Uuid, Uuid, String, bool, String, Option); + +/// One team membership of a listed user: (user id, team id, team name). +pub type UserTeamRow = (Uuid, Uuid, String); + +/// One page of live users, newest first, and the total matching +/// `search` (an `ILIKE` pattern on email / display name; `None` = all). +pub async fn list( + pool: &PgPool, + search: Option<&str>, + limit: i64, + offset: i64, +) -> Result<(i64, Vec), AppError> { + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users \ + WHERE deleted_at IS NULL \ + AND ($1::text IS NULL OR email ILIKE $1 OR display_name ILIKE $1)", + ) + .bind(search) + .fetch_one(pool) + .await?; + let users = sqlx::query_as::<_, User>( + "SELECT * FROM users \ + WHERE deleted_at IS NULL \ + AND ($1::text IS NULL OR email ILIKE $1 OR display_name ILIKE $1) \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(search) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + Ok((total, users)) +} + +/// [`list`], narrowed to `caller` plus every member of `team_ids`. +pub async fn list_in_teams( + pool: &PgPool, + caller: Uuid, + team_ids: &[Uuid], + search: Option<&str>, + limit: i64, + offset: i64, +) -> Result<(i64, Vec), AppError> { + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users u \ + WHERE u.deleted_at IS NULL \ + AND ($3::text IS NULL OR u.email ILIKE $3 OR u.display_name ILIKE $3) \ + AND ( \ + u.id = $1 \ + OR EXISTS ( \ + SELECT 1 FROM team_members tm \ + WHERE tm.user_id = u.id \ + AND tm.team_id = ANY($2) \ + ) \ + )", + ) + .bind(caller) + .bind(team_ids) + .bind(search) + .fetch_one(pool) + .await?; + let users = sqlx::query_as::<_, User>( + "SELECT u.* FROM users u \ + WHERE u.deleted_at IS NULL \ + AND ($3::text IS NULL OR u.email ILIKE $3 OR u.display_name ILIKE $3) \ + AND ( \ + u.id = $1 \ + OR EXISTS ( \ + SELECT 1 FROM team_members tm \ + WHERE tm.user_id = u.id \ + AND tm.team_id = ANY($2) \ + ) \ + ) \ + ORDER BY u.created_at DESC LIMIT $4 OFFSET $5", + ) + .bind(caller) + .bind(team_ids) + .bind(search) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + Ok((total, users)) +} + +/// Every role assignment of `user_ids`, system roles first, then by name. +pub async fn role_assignments_of( + pool: &PgPool, + user_ids: &[Uuid], +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT ra.user_id, r.id, r.name, r.is_system, ra.scope_kind, ra.scope_id \ + FROM rbac_role_assignments ra \ + JOIN rbac_roles r ON r.id = ra.role_id \ + WHERE ra.user_id = ANY($1) \ + ORDER BY r.is_system DESC, r.name ASC", + ) + .bind(user_ids) + .fetch_all(pool) + .await +} + +/// Every team membership of `user_ids`, by team name. +pub async fn teams_of(pool: &PgPool, user_ids: &[Uuid]) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT tm.user_id, t.id, t.name \ + FROM team_members tm \ + JOIN teams t ON t.id = tm.team_id \ + WHERE tm.user_id = ANY($1) \ + ORDER BY t.name ASC", + ) + .bind(user_ids) + .fetch_all(pool) + .await +} + +/// Is any user row — live or soft-deleted — using this email? +pub async fn email_taken(pool: &PgPool, email: &str) -> Result { + Ok( + sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") + .bind(email) + .fetch_one(pool) + .await?, + ) +} + +pub async fn insert( + conn: &mut PgConnection, + email: &str, + display_name: &str, + password_hash: &str, + password_change_required: bool, +) -> Result { + Ok(sqlx::query_as::<_, User>( + r#"INSERT INTO users (email, display_name, password_hash, password_change_required) + VALUES ($1, $2, $3, $4) RETURNING *"#, + ) + .bind(email) + .bind(display_name) + .bind(password_hash) + .bind(password_change_required) + .fetch_one(conn) + .await?) +} + /// Does an active (non-soft-deleted) user with this id exist? -pub async fn exists(pool: &sqlx::PgPool, id: Uuid) -> Result { +pub async fn exists(pool: &PgPool, id: Uuid) -> Result { let found: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)", ) @@ -30,38 +172,43 @@ pub async fn exists(pool: &sqlx::PgPool, id: Uuid) -> Result { Ok(found) } -/// Fetch the full user row. Returns `AppError::NotFound` if the id -/// doesn't resolve to an active row — saves every caller a manual -/// `.ok_or(AppError::NotFound(...))`. -pub async fn get_active(pool: &sqlx::PgPool, id: Uuid) -> Result { - sqlx::query_as::<_, User>( - "SELECT * FROM users WHERE id = $1 AND is_active = true AND deleted_at IS NULL", +/// Is there an active, live user with this id? +pub async fn active_exists(pool: &PgPool, id: Uuid) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_active = true AND deleted_at IS NULL)", ) .bind(id) - .fetch_optional(pool) - .await? - .ok_or_else(|| AppError::NotFound("User not found".into())) + .fetch_one(pool) + .await?) } -/// Look up the email for a user id. `None` if the row is -/// soft-deleted or inactive — callers that need the email typically -/// have a valid session already, so the caller decides what to do -/// with a None (usually: 401). -pub async fn find_email(pool: &sqlx::PgPool, id: Uuid) -> Result, AppError> { - let email = sqlx::query_scalar::<_, String>( - "SELECT email FROM users WHERE id = $1 AND is_active = true AND deleted_at IS NULL", - ) - .bind(id) - .fetch_optional(pool) - .await?; - Ok(email) +pub async fn set_display_name( + conn: &mut PgConnection, + id: Uuid, + display_name: &str, +) -> Result<(), AppError> { + sqlx::query("UPDATE users SET display_name = $1, updated_at = now() WHERE id = $2") + .bind(display_name) + .bind(id) + .execute(conn) + .await?; + Ok(()) +} + +pub async fn set_active(conn: &mut PgConnection, id: Uuid, active: bool) -> Result<(), AppError> { + sqlx::query("UPDATE users SET is_active = $1, updated_at = now() WHERE id = $2") + .bind(active) + .bind(id) + .execute(conn) + .await?; + Ok(()) } /// Replace the password hash and flag the next login for change. /// Also bumps `updated_at`, which the temp-password TTL grandfather /// check reads (see SEC-07 in the login path). pub async fn update_password_hash( - pool: &sqlx::PgPool, + pool: &PgPool, id: Uuid, password_hash: &str, force_change: bool, @@ -78,21 +225,90 @@ pub async fn update_password_hash( Ok(()) } -/// Soft-delete the user. Called by admin::delete_user inside a tx that -/// has already taken the super-admin guard lock and ensured the quorum -/// survives; the repository only touches the row. -pub async fn soft_delete( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - id: Uuid, - deleted_at: DateTime, -) -> Result { - let result = sqlx::query( - "UPDATE users SET deleted_at = $2, is_active = false, updated_at = now() \ - WHERE id = $1 AND deleted_at IS NULL", +/// Soft-delete a live user. Returns whether a row was touched. +pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> Result { + Ok(sqlx::query( + "UPDATE users SET deleted_at = now(), is_active = false, updated_at = now() WHERE id = $1 AND deleted_at IS NULL", ) .bind(id) - .bind(deleted_at) - .execute(&mut **tx) + .execute(conn) + .await? + .rows_affected()) +} + +/// Soft-delete every live API key of a user who was just disabled. +pub async fn disable_api_keys_of_disabled_user( + pool: &PgPool, + user_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE api_keys \ + SET is_active = false, deleted_at = now(), disabled_reason = 'user_disabled' \ + WHERE user_id = $1 AND deleted_at IS NULL", + ) + .bind(user_id) + .execute(pool) .await?; - Ok(result.rows_affected() > 0) + Ok(()) +} + +/// Soft-delete every live API key of a user being deleted. +pub async fn disable_api_keys_of_deleted_user( + conn: &mut PgConnection, + user_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE api_keys SET is_active = false, deleted_at = now(), disabled_reason = 'user_deleted' \ + WHERE user_id = $1 AND deleted_at IS NULL", + ) + .bind(user_id) + .execute(conn) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// rbac_role_assignments +// --------------------------------------------------------------------------- + +pub async fn delete_role_assignments( + conn: &mut PgConnection, + user_id: Uuid, +) -> Result<(), AppError> { + sqlx::query("DELETE FROM rbac_role_assignments WHERE user_id = $1") + .bind(user_id) + .execute(conn) + .await?; + Ok(()) +} + +/// Assign a role (an existing assignment is left alone) and return the +/// role's (name, is_system) — `None` when the role doesn't exist. The raw +/// error comes back so the caller can name an unknown role id. +pub async fn insert_role_assignment( + conn: &mut PgConnection, + user_id: Uuid, + role_id: Uuid, + scope_kind: &str, + scope_id: Option, + assigned_by: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as( + "WITH ins AS (\ + INSERT INTO rbac_role_assignments \ + (user_id, role_id, scope_kind, scope_id, assigned_by) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT DO NOTHING \ + RETURNING role_id\ + ) \ + SELECT r.name, r.is_system FROM rbac_roles r \ + WHERE r.id = $2", + ) + .bind(user_id) + .bind(role_id) + .bind(scope_kind) + .bind(scope_id) + .bind(assigned_by) + .fetch_optional(conn) + .await } diff --git a/crates/test-support/tests/admin_identity.rs b/crates/test-support/tests/admin_identity.rs new file mode 100644 index 00000000..9ad45f38 --- /dev/null +++ b/crates/test-support/tests/admin_identity.rs @@ -0,0 +1,974 @@ +//! The identity admin endpoints end to end: users, roles and teams. +//! +//! Many branches here (scoped listings, role reassignment on delete, +//! the team member cap, PATCH null-vs-absent) were only reached through +//! the UI before; this file pins what each one reads and writes, so +//! moving their SQL around (into `services::*_repository`) is checked +//! rather than assumed. + +use serde_json::Value; +use think_watch_test_support::prelude::*; + +async fn login_as(app: &TestApp, user: &fixtures::SeededUser) -> TestClient { + let con = app.console_client(); + con.post( + "/api/auth/login", + json!({"email": user.user.email, "password": user.plaintext_password}), + ) + .await + .unwrap() + .assert_ok(); + con +} + +async fn get(con: &TestClient, path: &str) -> Value { + let resp = con.get(path).await.unwrap(); + resp.assert_ok(); + resp.json().unwrap() +} + +async fn role_id(app: &TestApp, name: &str) -> Uuid { + sqlx::query_scalar("SELECT id FROM rbac_roles WHERE name = $1") + .bind(name) + .fetch_one(&app.db) + .await + .unwrap() +} + +async fn create_team(con: &TestClient, name: &str) -> String { + let resp = con + .post( + "/api/admin/teams", + json!({"name": name, "description": " d "}), + ) + .await + .unwrap(); + resp.assert_ok(); + let team: Value = resp.json().unwrap(); + team["id"].as_str().expect("team id").to_string() +} + +async fn create_role(con: &TestClient, name: &str, actions: &[&str]) -> String { + let resp = con + .post( + "/api/admin/roles", + json!({ + "name": name, + "description": "identity test", + "policy_document": { + "Version": "2024-01-01", + "Statement": [{"Sid": "T", "Effect": "Allow", "Action": actions, "Resource": "*"}] + } + }), + ) + .await + .unwrap(); + resp.assert_ok(); + let role: Value = resp.json().unwrap(); + role["id"].as_str().expect("role id").to_string() +} + +async fn api_key_state(app: &TestApp, id: Uuid) -> (bool, bool, Option) { + sqlx::query_as( + "SELECT is_active, deleted_at IS NOT NULL, disabled_reason FROM api_keys WHERE id = $1", + ) + .bind(id) + .fetch_one(&app.db) + .await + .unwrap() +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn users_are_created_with_roles_and_listed_with_search() { + let app = TestApp::spawn().await; + let (con, admin) = admin_session_with_user(&app).await; + let developer = role_id(&app, "developer").await; + let viewer = role_id(&app, "viewer").await; + let team = create_team(&con, &unique_name("ident-team")).await; + + // A tag with LIKE wildcards in it: the search must treat them + // literally. + let tag = format!("x_{}%", Uuid::new_v4().simple()); + let email = unique_email(); + let resp = con + .post( + "/api/admin/users", + json!({ + "email": email.to_uppercase(), + "display_name": format!("Probe {tag}"), + "role_assignments": [ + {"role_id": developer}, + {"role_id": viewer, "scope": format!("team:{team}")}, + ], + }), + ) + .await + .unwrap(); + resp.assert_ok(); + let created: Value = resp.json().unwrap(); + let user_id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["email"], email.as_str(), "email is normalized"); + assert!( + created["generated_password"].is_string(), + "no password given → one is generated: {created}" + ); + let assignments = created["role_assignments"].as_array().unwrap(); + assert_eq!(assignments.len(), 2, "{created}"); + assert_eq!(assignments[0]["name"], "developer"); + assert_eq!(assignments[0]["scope"], "global"); + assert_eq!(assignments[0]["is_system"], true); + assert_eq!(assignments[1]["name"], "viewer"); + assert_eq!(assignments[1]["scope"], format!("team:{team}")); + let force_change: bool = + sqlx::query_scalar("SELECT password_change_required FROM users WHERE id = $1::uuid") + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(force_change); + + // Supplying a password: nothing generated, no forced change. + let resp = con + .post( + "/api/admin/users", + json!({"email": unique_email(), "display_name": "With pwd", "password": "Supplied_Pwd_1234!"}), + ) + .await + .unwrap(); + resp.assert_ok(); + let with_pwd: Value = resp.json().unwrap(); + assert!(with_pwd.get("generated_password").is_none(), "{with_pwd}"); + assert_eq!(with_pwd["role_assignments"], json!([])); + + // Duplicate email, unknown role, bad scope. + con.post( + "/api/admin/users", + json!({"email": email, "display_name": "Dup"}), + ) + .await + .unwrap() + .assert_status(409); + con.post( + "/api/admin/users", + json!({"email": unique_email(), "display_name": "R", "role_assignments": [{"role_id": Uuid::new_v4()}]}), + ) + .await + .unwrap() + .assert_status(400); + con.post( + "/api/admin/users", + json!({"email": unique_email(), "display_name": "S", "role_assignments": [{"role_id": developer, "scope": "org:x"}]}), + ) + .await + .unwrap() + .assert_status(400); + // The failed inserts above rolled back: no stray user row. + let strays: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE display_name IN ('R', 'S', 'Dup')") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(strays, 0); + + // Add the new user to the team so the list reports it. + con.post( + &format!("/api/admin/teams/{team}/members"), + json!({"user_id": user_id}), + ) + .await + .unwrap() + .assert_ok(); + + // Search matches the literal tag only. + let list = get( + &con, + &format!("/api/admin/users?search={}", urlencode(&tag)), + ) + .await; + assert_eq!(list["total"], 1, "{list}"); + let row = &list["data"][0]; + assert_eq!(row["id"], user_id.as_str()); + assert_eq!(row["role_assignments"].as_array().unwrap().len(), 2); + assert_eq!(row["teams"][0]["id"], team.as_str()); + assert_eq!(row["permissions"], json!([])); + let none = get(&con, "/api/admin/users?search=x%25nomatch").await; + assert_eq!(none["total"], 0); + assert_eq!(none["data"], json!([])); + + // Unfiltered: every live user, newest first, paginated. + let page = get(&con, "/api/admin/users?per_page=1&page=2").await; + assert_eq!(page["total"], 3, "admin + two created: {page}"); + assert_eq!(page["page"], 2); + assert_eq!(page["per_page"], 1); + assert_eq!(page["data"].as_array().unwrap().len(), 1); + let all = get(&con, "/api/admin/users").await; + let ids: Vec<&str> = all["data"] + .as_array() + .unwrap() + .iter() + .map(|u| u["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids[2], admin.user.id.to_string(), "oldest last: {all}"); + + let supers = get(&con, "/api/admin/users/super-admin-ids").await; + assert_eq!(supers["ids"], json!([admin.user.id])); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_admin_cannot_hand_out_super_admin() { + let app = TestApp::spawn().await; + let admin = fixtures::create_user_with_role(&app.db, "admin", "global", None) + .await + .unwrap(); + let con = login_as(&app, &admin).await; + let super_admin = role_id(&app, "super_admin").await; + con.post( + "/api/admin/users", + json!({"email": unique_email(), "display_name": "Esc", "role_assignments": [{"role_id": super_admin}]}), + ) + .await + .unwrap() + .assert_status(403); + + let target = fixtures::create_random_user(&app.db).await.unwrap(); + con.patch( + &format!("/api/admin/users/{}", target.user.id), + json!({"role_assignments": [{"role_id": super_admin}]}), + ) + .await + .unwrap() + .assert_status(403); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_team_scoped_reader_sees_only_its_teams_and_their_members() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + let scoped_team = create_team(&con, &unique_name("scoped")).await; + let own_team = create_team(&con, &unique_name("own")).await; + let other_team = create_team(&con, &unique_name("other")).await; + let role = create_role( + &con, + &unique_name("team-reader"), + &["teams:read", "users:read"], + ) + .await; + + let reader = fixtures::create_random_user(&app.db).await.unwrap(); + sqlx::query( + "INSERT INTO rbac_role_assignments (user_id, role_id, scope_kind, scope_id, assigned_by) \ + VALUES ($1, $2::uuid, 'team', $3::uuid, $1)", + ) + .bind(reader.user.id) + .bind(&role) + .bind(&scoped_team) + .execute(&app.db) + .await + .unwrap(); + let member = fixtures::create_random_user(&app.db).await.unwrap(); + let outsider = fixtures::create_random_user(&app.db).await.unwrap(); + for (team, user) in [ + (&scoped_team, member.user.id), + (&own_team, reader.user.id), + (&other_team, outsider.user.id), + ] { + con.post( + &format!("/api/admin/teams/{team}/members"), + json!({"user_id": user}), + ) + .await + .unwrap() + .assert_ok(); + } + + let reader_con = login_as(&app, &reader).await; + let teams = get(&reader_con, "/api/admin/teams").await; + let mut seen: Vec<&str> = teams + .as_array() + .unwrap() + .iter() + .map(|t| t["id"].as_str().unwrap()) + .collect(); + seen.sort(); + let mut want = vec![scoped_team.as_str(), own_team.as_str()]; + want.sort(); + assert_eq!(seen, want, "{teams}"); + + let users = get(&reader_con, "/api/admin/users").await; + assert_eq!( + users["total"], 2, + "self + the scoped team's member: {users}" + ); + let ids: Vec<&str> = users["data"] + .as_array() + .unwrap() + .iter() + .map(|u| u["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&reader.user.id.to_string().as_str())); + assert!(ids.contains(&member.user.id.to_string().as_str())); + let searched = get( + &reader_con, + &format!("/api/admin/users?search={}", urlencode(&member.user.email)), + ) + .await; + assert_eq!(searched["total"], 1, "{searched}"); + let hidden = get( + &reader_con, + &format!( + "/api/admin/users?search={}", + urlencode(&outsider.user.email) + ), + ) + .await; + assert_eq!(hidden["total"], 0, "{hidden}"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn updating_a_user_replaces_roles_and_disabling_revokes_keys() { + let app = TestApp::spawn().await; + let (con, admin) = admin_session_with_user(&app).await; + let target = fixtures::create_random_user(&app.db).await.unwrap(); + let key = fixtures::create_api_key(&app.db, target.user.id, "k", &["ai_gateway"], None, None) + .await + .unwrap(); + let viewer = role_id(&app, "viewer").await; + let path = format!("/api/admin/users/{}", target.user.id); + + con.patch(&format!("/api/admin/users/{}", Uuid::new_v4()), json!({})) + .await + .unwrap() + .assert_status(404); + con.patch(&path, json!({"display_name": " "})) + .await + .unwrap() + .assert_status(400); + con.patch( + &format!("/api/admin/users/{}", admin.user.id), + json!({"is_active": false}), + ) + .await + .unwrap() + .assert_status(400); + con.patch( + &format!("/api/admin/users/{}", admin.user.id), + json!({"role_assignments": []}), + ) + .await + .unwrap() + .assert_status(400); + + let resp = con + .patch( + &path, + json!({"display_name": " Renamed ", "role_assignments": [{"role_id": viewer}]}), + ) + .await + .unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body["status"], "updated"); + let row = get( + &con, + &format!("/api/admin/users?search={}", urlencode(&target.user.email)), + ) + .await; + let row = &row["data"][0]; + assert_eq!(row["display_name"], "Renamed"); + let roles: Vec<&str> = row["role_assignments"] + .as_array() + .unwrap() + .iter() + .map(|r| r["name"].as_str().unwrap()) + .collect(); + assert_eq!(roles, vec!["viewer"], "developer replaced: {row}"); + assert_eq!(row["is_active"], true); + assert_eq!(api_key_state(&app, key.row.id).await, (true, false, None)); + + con.patch(&path, json!({"is_active": false})) + .await + .unwrap() + .assert_ok(); + let row = get( + &con, + &format!("/api/admin/users?search={}", urlencode(&target.user.email)), + ) + .await; + assert_eq!(row["data"][0]["is_active"], false); + assert_eq!( + api_key_state(&app, key.row.id).await, + (false, true, Some("user_disabled".into())) + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn deleting_a_user_soft_deletes_it_and_its_keys() { + let app = TestApp::spawn().await; + let (con, admin) = admin_session_with_user(&app).await; + let target = fixtures::create_random_user(&app.db).await.unwrap(); + let key = fixtures::create_api_key(&app.db, target.user.id, "k", &["ai_gateway"], None, None) + .await + .unwrap(); + + con.delete(&format!("/api/admin/users/{}", admin.user.id)) + .await + .unwrap() + .assert_status(400); + con.delete(&format!("/api/admin/users/{}", Uuid::new_v4())) + .await + .unwrap() + .assert_status(404); + + let resp = con + .delete(&format!("/api/admin/users/{}", target.user.id)) + .await + .unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body["status"], "deleted"); + let (active, deleted): (bool, bool) = + sqlx::query_as("SELECT is_active, deleted_at IS NOT NULL FROM users WHERE id = $1") + .bind(target.user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!((active, deleted), (false, true)); + assert_eq!( + api_key_state(&app, key.row.id).await, + (false, true, Some("user_deleted".into())) + ); + // Gone from the list, and a second delete finds nothing. + let list = get(&con, "/api/admin/users").await; + assert_eq!(list["total"], 1, "{list}"); + con.delete(&format!("/api/admin/users/{}", target.user.id)) + .await + .unwrap() + .assert_status(404); + // Reset-password on a deleted user is a 404 too. + con.post_empty(&format!( + "/api/admin/users/{}/reset-password", + target.user.id + )) + .await + .unwrap() + .assert_status(404); +} + +// --------------------------------------------------------------------------- +// Roles +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_role_is_created_updated_listed_and_its_members_shown() { + let app = TestApp::spawn().await; + let (con, admin) = admin_session_with_user(&app).await; + let name = unique_name("ident-role"); + let resp = con + .post( + "/api/admin/roles", + json!({ + "name": format!(" {name} "), + "description": "first", + "policy_document": {"Version": "2024-01-01", "Statement": [{"Effect": "Allow", "Action": ["models:read"], "Resource": "*"}]} + }), + ) + .await + .unwrap(); + resp.assert_ok(); + let role: Value = resp.json().unwrap(); + let id = role["id"].as_str().unwrap().to_string(); + assert_eq!(role["name"], name.as_str()); + assert_eq!(role["is_system"], false); + assert_eq!(role["user_count"], 0); + assert_eq!(role["created_by_email"], admin.user.email.as_str()); + + // Same name again → 400. + con.post( + "/api/admin/roles", + json!({"name": name, "policy_document": {"Version": "2024-01-01", "Statement": []}}), + ) + .await + .unwrap() + .assert_status(400); + + // Two members: one global, one team-scoped. + let team = create_team(&con, &unique_name("role-team")).await; + let a = fixtures::create_user_with_role(&app.db, &name, "global", None) + .await + .unwrap(); + let b = fixtures::create_user_with_role(&app.db, &name, "team", Some(team.parse().unwrap())) + .await + .unwrap(); + + // PATCH: absent description is kept, rename works. + let renamed = format!("{name}-2"); + let resp = con + .patch(&format!("/api/admin/roles/{id}"), json!({"name": renamed})) + .await + .unwrap(); + resp.assert_ok(); + let patched: Value = resp.json().unwrap(); + assert_eq!(patched["name"], renamed.as_str()); + assert_eq!(patched["description"], "first"); + assert_eq!(patched["user_count"], 2); + // JSON null clears it. + let resp = con + .patch( + &format!("/api/admin/roles/{id}"), + json!({"description": null}), + ) + .await + .unwrap(); + resp.assert_ok(); + let cleared: Value = resp.json().unwrap(); + assert!(cleared["description"].is_null(), "{cleared}"); + assert_eq!(cleared["name"], renamed.as_str()); + + con.patch(&format!("/api/admin/roles/{}", Uuid::new_v4()), json!({})) + .await + .unwrap() + .assert_status(404); + let developer = role_id(&app, "developer").await; + con.patch( + &format!("/api/admin/roles/{developer}"), + json!({"name": "renamed-dev"}), + ) + .await + .unwrap() + .assert_status(400); + con.post_empty(&format!("/api/admin/roles/{}/reset", Uuid::new_v4())) + .await + .unwrap() + .assert_status(404); + + // List: system roles first, counts joined in. + let list = get(&con, "/api/admin/roles").await; + let items = list["items"].as_array().unwrap(); + let first_custom = items.iter().position(|r| r["is_system"] == false).unwrap(); + assert!(items[..first_custom].iter().all(|r| r["is_system"] == true)); + let ours = items.iter().find(|r| r["id"] == id.as_str()).unwrap(); + assert_eq!(ours["user_count"], 2); + let supers = items.iter().find(|r| r["name"] == "super_admin").unwrap(); + assert_eq!(supers["user_count"], 1); + + // Members, ordered by email, with encoded scopes. + let members = get(&con, &format!("/api/admin/roles/{id}/members")).await; + let members = members["items"].as_array().unwrap(); + assert_eq!(members.len(), 2); + let mut want = vec![ + (a.user.email.clone(), "global".to_string()), + (b.user.email.clone(), format!("team:{team}")), + ]; + want.sort(); + let got: Vec<(String, String)> = members + .iter() + .map(|m| { + ( + m["email"].as_str().unwrap().to_string(), + m["scope"].as_str().unwrap().to_string(), + ) + }) + .collect(); + assert_eq!(got, want); + con.get(&format!("/api/admin/roles/{}/members", Uuid::new_v4())) + .await + .unwrap() + .assert_status(404); + con.get(&format!("/api/admin/roles/{}/history", Uuid::new_v4())) + .await + .unwrap() + .assert_status(404); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn deleting_a_role_reassigns_its_members() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + let from_name = unique_name("from"); + let from = create_role(&con, &from_name, &["models:read"]).await; + let to = create_role(&con, &unique_name("to"), &["models:read"]).await; + let empty = create_role(&con, &unique_name("empty"), &["models:read"]).await; + let a = fixtures::create_user_with_role(&app.db, &from_name, "global", None) + .await + .unwrap(); + fixtures::create_user_with_role(&app.db, &from_name, "global", None) + .await + .unwrap(); + + let developer = role_id(&app, "developer").await; + con.delete(&format!("/api/admin/roles/{developer}")) + .await + .unwrap() + .assert_status(400); + con.delete(&format!("/api/admin/roles/{}", Uuid::new_v4())) + .await + .unwrap() + .assert_status(404); + con.delete(&format!("/api/admin/roles/{from}")) + .await + .unwrap() + .assert_status(400); + con.delete(&format!("/api/admin/roles/{from}?reassign_to={from}")) + .await + .unwrap() + .assert_status(400); + con.delete(&format!( + "/api/admin/roles/{from}?reassign_to={}", + Uuid::new_v4() + )) + .await + .unwrap() + .assert_status(400); + + let resp = con + .delete(&format!("/api/admin/roles/{from}?reassign_to={to}")) + .await + .unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"deleted": true, "reassigned": 2})); + let gone: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rbac_roles WHERE id = $1::uuid)") + .bind(&from) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(!gone); + let members = get(&con, &format!("/api/admin/roles/{to}/members")).await; + let emails: Vec<&str> = members["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["email"].as_str().unwrap()) + .collect(); + assert_eq!(emails.len(), 2); + assert!(emails.contains(&a.user.email.as_str())); + + let resp = con + .delete(&format!("/api/admin/roles/{empty}")) + .await + .unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"deleted": true, "reassigned": 0})); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn role_history_reads_the_audit_log() { + let app = TestApp::spawn_with_clickhouse().await; + let con = admin_session(&app).await; + let id = create_role(&con, &unique_name("hist"), &["models:read"]).await; + let mut actions = Vec::new(); + for _ in 0..40 { + let history = get(&con, &format!("/api/admin/roles/{id}/history")).await; + actions = history["items"] + .as_array() + .unwrap() + .iter() + .map(|e| e["action"].as_str().unwrap().to_string()) + .collect(); + if !actions.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + assert_eq!(actions, vec!["role.created".to_string()]); +} + +// --------------------------------------------------------------------------- +// Teams +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_team_is_read_updated_and_deleted() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + let name = unique_name("ident-team"); + let id = create_team(&con, &name).await; + let other = unique_name("ident-other"); + create_team(&con, &other).await; + con.post("/api/admin/teams", json!({"name": name})) + .await + .unwrap() + .assert_status(409); + + let member = fixtures::create_random_user(&app.db).await.unwrap(); + con.post( + &format!("/api/admin/teams/{id}/members"), + json!({"user_id": member.user.id}), + ) + .await + .unwrap() + .assert_ok(); + + let team = get(&con, &format!("/api/admin/teams/{id}")).await; + assert_eq!(team["name"], name.as_str()); + assert_eq!(team["description"], "d", "trimmed on create"); + assert_eq!(team["member_count"], 1); + con.get(&format!("/api/admin/teams/{}", Uuid::new_v4())) + .await + .unwrap() + .assert_status(404); + + let list = get(&con, "/api/admin/teams").await; + let names: Vec<&str> = list + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted, "ordered by name"); + let ours = list + .as_array() + .unwrap() + .iter() + .find(|t| t["id"] == id.as_str()) + .unwrap(); + assert_eq!(ours["member_count"], 1); + + // PATCH: absent keeps, null clears, whitespace name refused, + // a taken name conflicts. + let path = format!("/api/admin/teams/{id}"); + let renamed = format!("{name}-2"); + let resp = con.patch(&path, json!({"name": renamed})).await.unwrap(); + resp.assert_ok(); + let t: Value = resp.json().unwrap(); + assert_eq!(t["name"], renamed.as_str()); + assert_eq!(t["description"], "d"); + let resp = con + .patch(&path, json!({"description": null})) + .await + .unwrap(); + resp.assert_ok(); + let t: Value = resp.json().unwrap(); + assert!(t["description"].is_null(), "{t}"); + assert_eq!(t["name"], renamed.as_str()); + con.patch(&path, json!({"name": " "})) + .await + .unwrap() + .assert_status(400); + con.patch(&path, json!({"name": other})) + .await + .unwrap() + .assert_status(409); + con.patch( + &format!("/api/admin/teams/{}", Uuid::new_v4()), + json!({"name": "x"}), + ) + .await + .unwrap() + .assert_status(404); + + let resp = con.delete(&path).await.unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"status": "deleted"})); + con.delete(&path).await.unwrap().assert_status(404); + let members: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM team_members WHERE team_id = $1::uuid") + .bind(&id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(members, 0, "memberships cascade"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_membership_is_capped_idempotent_and_hides_deleted_users() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + let user = fixtures::create_random_user(&app.db).await.unwrap(); + + let first = create_team(&con, &unique_name("cap")).await; + con.post( + &format!("/api/admin/teams/{first}/members"), + json!({"user_id": Uuid::new_v4()}), + ) + .await + .unwrap() + .assert_status(404); + + let mut teams = vec![first]; + for _ in 1..10 { + teams.push(create_team(&con, &unique_name("cap")).await); + } + for team in &teams { + let resp = con + .post( + &format!("/api/admin/teams/{team}/members"), + json!({"user_id": user.user.id}), + ) + .await + .unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"status": "added"})); + } + // At the cap: re-adding an existing membership is still fine, + // an eleventh team is refused. + con.post( + &format!("/api/admin/teams/{}/members", teams[0]), + json!({"user_id": user.user.id}), + ) + .await + .unwrap() + .assert_ok(); + let eleventh = create_team(&con, &unique_name("cap")).await; + con.post( + &format!("/api/admin/teams/{eleventh}/members"), + json!({"user_id": user.user.id}), + ) + .await + .unwrap() + .assert_status(400); + + // A deactivated user can't be added. + let inactive = fixtures::create_random_user(&app.db).await.unwrap(); + con.patch( + &format!("/api/admin/users/{}", inactive.user.id), + json!({"is_active": false}), + ) + .await + .unwrap() + .assert_ok(); + con.post( + &format!("/api/admin/teams/{eleventh}/members"), + json!({"user_id": inactive.user.id}), + ) + .await + .unwrap() + .assert_status(404); + + // Roster: in join order, soft-deleted users hidden. + let second = fixtures::create_random_user(&app.db).await.unwrap(); + con.post( + &format!("/api/admin/teams/{}/members", teams[0]), + json!({"user_id": second.user.id}), + ) + .await + .unwrap() + .assert_ok(); + let roster = get(&con, &format!("/api/admin/teams/{}/members", teams[0])).await; + let ids: Vec<&str> = roster + .as_array() + .unwrap() + .iter() + .map(|m| m["user_id"].as_str().unwrap()) + .collect(); + assert_eq!( + ids, + vec![user.user.id.to_string(), second.user.id.to_string()] + ); + assert_eq!(roster[0]["email"], user.user.email.as_str()); + con.delete(&format!("/api/admin/users/{}", user.user.id)) + .await + .unwrap() + .assert_ok(); + let roster = get(&con, &format!("/api/admin/teams/{}/members", teams[0])).await; + assert_eq!(roster.as_array().unwrap().len(), 1, "{roster}"); + + // Remove: once fine, twice a 404. + let path = format!("/api/admin/teams/{}/members/{}", teams[0], second.user.id); + let resp = con.delete(&path).await.unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"status": "removed"})); + con.delete(&path).await.unwrap().assert_status(404); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_member_reads_its_own_team_without_teams_read() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + let team = create_team(&con, &unique_name("own")).await; + let other = create_team(&con, &unique_name("other")).await; + // Developers hold no teams:read at all. + let dev = fixtures::create_random_user(&app.db).await.unwrap(); + con.post( + &format!("/api/admin/teams/{team}/members"), + json!({"user_id": dev.user.id}), + ) + .await + .unwrap() + .assert_ok(); + let dev_con = login_as(&app, &dev).await; + let roster = get(&dev_con, &format!("/api/admin/teams/{team}/members")).await; + assert_eq!(roster[0]["user_id"], dev.user.id.to_string()); + dev_con + .get(&format!("/api/admin/teams/{other}/members")) + .await + .unwrap() + .assert_status(403); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_roles_are_assigned_listed_and_removed() { + let app = TestApp::spawn().await; + let con = admin_session(&app).await; + let team = create_team(&con, &unique_name("roles")).await; + let custom_name = unique_name("a-custom"); + let custom = create_role(&con, &custom_name, &["models:read"]).await; + let viewer = role_id(&app, "viewer").await; + let path = format!("/api/admin/teams/{team}/roles"); + + for role in [json!(custom), json!(viewer), json!(viewer)] { + let resp = con.post(&path, json!({"role_id": role})).await.unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"status": "assigned"})); + } + let roles = get(&con, &path).await; + let names: Vec<&str> = roles + .as_array() + .unwrap() + .iter() + .map(|r| r["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["viewer", custom_name.as_str()], "system first"); + assert_eq!(roles[0]["role_id"], viewer.to_string()); + assert_eq!(roles[0]["is_system"], true); + assert!(roles[0]["assigned_at"].is_string()); + + let resp = con.delete(&format!("{path}/{viewer}")).await.unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body, json!({"status": "removed"})); + // Removing again is still a 200. + con.delete(&format!("{path}/{viewer}")) + .await + .unwrap() + .assert_ok(); + let roles = get(&con, &path).await; + assert_eq!(roles.as_array().unwrap().len(), 1, "{roles}"); +} + +/// Percent-encode a query value (the handful of characters the tests +/// put in search terms). +fn urlencode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +}