AkurAI Build
Menu

Public repository / 8e8b37791b10d6119df0a2cff4be3c35376e8ea1

AkurAI-IDP / src/routes/admin/groups.rs

Latest change 5526f1231762e5175b9d0a8a7a09a61be31302ef - admin: drop the now-unused post import (clippy) by Ólafur Búi Ólafsson

use axum::{
    Json, Router,
    extract::{Path, Query},
    http::StatusCode,
    response::IntoResponse,
    routing::{delete, get},
};
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::db::with_db;
use crate::lib::audit;
use crate::lib::crypto::generate_secure_token;

#[derive(Deserialize)]
pub struct ListQuery {
    pub tenant_id: Option<String>,
}

#[derive(Serialize)]
pub struct GroupRow {
    pub id: String,
    pub tenant_id: String,
    pub name: String,
    pub description: Option<String>,
}

#[derive(Deserialize)]
pub struct CreateGroup {
    pub name: String,
    pub tenant_id: String,
    pub description: Option<String>,
}

#[derive(Deserialize)]
pub struct AddMember {
    pub user_id: String,
}

pub fn router() -> Router {
    Router::new()
        .route("/", get(list_groups).post(create_group))
        .route("/{id}", delete(delete_group))
        .route("/{id}/members", get(list_members).post(add_member))
        .route("/{id}/members/{userId}", delete(remove_member))
}

async fn list_groups(Query(q): Query<ListQuery>) -> impl IntoResponse {
    let result = with_db(|conn| {
        let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match &q.tenant_id {
            Some(tid) => (
                "SELECT id, tenant_id, name, description FROM groups WHERE tenant_id = ?1"
                    .to_string(),
                vec![Box::new(tid.clone()) as Box<dyn rusqlite::types::ToSql>],
            ),
            None => (
                "SELECT id, tenant_id, name, description FROM groups".to_string(),
                vec![],
            ),
        };
        let mut stmt = conn.prepare(&sql)?;
        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
            params.iter().map(|p| p.as_ref()).collect();
        let rows = stmt.query_map(params_refs.as_slice(), |row| {
            Ok(GroupRow {
                id: row.get(0)?,
                tenant_id: row.get(1)?,
                name: row.get(2)?,
                description: row.get(3)?,
            })
        })?;
        let mut groups = Vec::new();
        for r in rows {
            groups.push(r?);
        }
        Ok::<_, rusqlite::Error>(groups)
    });

    match result {
        Ok(groups) => Json(json!(groups)).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

async fn create_group(Json(body): Json<CreateGroup>) -> impl IntoResponse {
    let id = generate_secure_token(16);

    let result = with_db(|conn| {
        conn.execute(
            "INSERT INTO groups (id, tenant_id, name, description) VALUES (?1, ?2, ?3, ?4)",
            rusqlite::params![id, body.tenant_id, body.name, body.description],
        )
    });

    match result {
        Ok(_) => {
            audit::log_audit(
                Some(&body.tenant_id),
                None,
                audit::GROUP_CREATED,
                None,
                Some(&json!({"name": body.name, "group_id": id})),
            );
            (
                StatusCode::CREATED,
                Json(json!({"id": id, "name": body.name, "tenant_id": body.tenant_id})),
            )
                .into_response()
        }
        Err(e) => {
            let msg = e.to_string();
            if msg.contains("UNIQUE") {
                (
                    StatusCode::CONFLICT,
                    Json(json!({"error": "group with this name already exists in tenant"})),
                )
                    .into_response()
            } else {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    Json(json!({"error": msg})),
                )
                    .into_response()
            }
        }
    }
}

async fn delete_group(Path(id): Path<String>) -> impl IntoResponse {
    let result = with_db(|conn| conn.execute("DELETE FROM groups WHERE id = ?1", [&id]));

    match result {
        Ok(0) => (
            StatusCode::NOT_FOUND,
            Json(json!({"error": "group not found"})),
        )
            .into_response(),
        Ok(_) => {
            audit::log_audit(
                None,
                None,
                audit::GROUP_DELETED,
                None,
                Some(&json!({"group_id": id})),
            );
            Json(json!({"ok": true})).into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

#[derive(Serialize)]
pub struct MemberRow {
    pub user_id: String,
    pub email: String,
}

/// The group's members (user id and email), oldest membership first by user id.
pub fn members_of(conn: &rusqlite::Connection, group_id: &str) -> rusqlite::Result<Vec<MemberRow>> {
    let mut stmt = conn.prepare(
        "SELECT u.id, u.email FROM user_groups ug JOIN users u ON u.id = ug.user_id
         WHERE ug.group_id = ?1 ORDER BY u.id",
    )?;
    let rows = stmt.query_map([group_id], |row| {
        Ok(MemberRow {
            user_id: row.get(0)?,
            email: row.get(1)?,
        })
    })?;
    rows.collect()
}

/// `GET /admin/groups/{id}/members` — who is in the group (OLI-288: the chat's
/// workspace screen needs memberships, not only add/remove).
async fn list_members(Path(id): Path<String>) -> impl IntoResponse {
    let exists =
        with_db(|conn| conn.query_row("SELECT 1 FROM groups WHERE id = ?1", [&id], |_| Ok(())));
    if matches!(exists, Err(rusqlite::Error::QueryReturnedNoRows)) {
        return (
            StatusCode::NOT_FOUND,
            Json(json!({"error": "group not found"})),
        )
            .into_response();
    }
    match with_db(|conn| members_of(conn, &id)) {
        Ok(members) => Json(json!(members)).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

async fn add_member(Path(id): Path<String>, Json(body): Json<AddMember>) -> impl IntoResponse {
    let result = with_db(|conn| {
        conn.execute(
            "INSERT INTO user_groups (user_id, group_id) VALUES (?1, ?2)",
            rusqlite::params![body.user_id, id],
        )
    });

    match result {
        Ok(_) => {
            audit::log_audit(
                None,
                Some(&body.user_id),
                audit::GROUP_MEMBER_ADDED,
                None,
                Some(&json!({"group_id": id, "user_id": body.user_id})),
            );
            (StatusCode::CREATED, Json(json!({"ok": true}))).into_response()
        }
        Err(e) => {
            let msg = e.to_string();
            if msg.contains("UNIQUE") || msg.contains("PRIMARY") {
                (
                    StatusCode::CONFLICT,
                    Json(json!({"error": "user already in group"})),
                )
                    .into_response()
            } else if msg.contains("FOREIGN") {
                (
                    StatusCode::BAD_REQUEST,
                    Json(json!({"error": "user or group does not exist"})),
                )
                    .into_response()
            } else {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    Json(json!({"error": msg})),
                )
                    .into_response()
            }
        }
    }
}

async fn remove_member(Path((id, user_id)): Path<(String, String)>) -> impl IntoResponse {
    let result = with_db(|conn| {
        conn.execute(
            "DELETE FROM user_groups WHERE group_id = ?1 AND user_id = ?2",
            rusqlite::params![id, user_id],
        )
    });

    match result {
        Ok(0) => (
            StatusCode::NOT_FOUND,
            Json(json!({"error": "membership not found"})),
        )
            .into_response(),
        Ok(_) => {
            audit::log_audit(
                None,
                Some(&user_id),
                audit::GROUP_MEMBER_REMOVED,
                None,
                Some(&json!({"group_id": id, "user_id": user_id})),
            );
            Json(json!({"ok": true})).into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::Connection;

    fn seeded() -> Connection {
        let conn = Connection::open_in_memory().unwrap();
        crate::db::schema::create_tables(&conn);
        conn.execute(
            "INSERT INTO tenants (id, name, slug, created_at) VALUES ('t1','T','t',1)",
            [],
        )
        .unwrap();
        for (id, email) in [("u1", "a@t.is"), ("u2", "b@t.is")] {
            conn.execute(
                "INSERT INTO users (id, tenant_id, email, password_hash, email_verified, mfa_enabled, force_password_change, failed_attempts, created_at, updated_at) VALUES (?1, 't1', ?2, 'HASH', 0, 0, 0, 0, 1, 1)",
                rusqlite::params![id, email],
            )
            .unwrap();
        }
        conn.execute(
            "INSERT INTO groups (id, tenant_id, name, description) VALUES ('g1','t1','hr',NULL)",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO user_groups (user_id, group_id) VALUES ('u2','g1')",
            [],
        )
        .unwrap();
        conn
    }

    #[test]
    fn members_of_lists_user_ids_and_emails_of_the_group_only() {
        let conn = seeded();
        let m = members_of(&conn, "g1").unwrap();
        assert_eq!(m.len(), 1);
        assert_eq!(
            (m[0].user_id.as_str(), m[0].email.as_str()),
            ("u2", "b@t.is")
        );
        assert!(members_of(&conn, "nope").unwrap().is_empty());
    }
}