linkrs-server 0.1.0

Linkrs network service layer (HTTP/gRPC)
Documentation
use axum::{
    extract::{Extension, Path, State},
    http::StatusCode,
    response::Json as JsonResponse,
    Json,
};
use linkrs_wire::meta::{
    CreateUserRequest, GrantRoleRequest, ResetPasswordRequest, RevokeRoleRequest, UserListItem,
    UserListResponse,
};

use crate::http::{error::HttpError, state::AppState};
use crate::storage::{
    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
};

fn session_user<S>(state: &AppState<S>, session_id: i64) -> Result<String, HttpError>
where
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
{
    let graph_service = state.server.get_graph_service();
    let session = graph_service
        .get_session_manager()
        .find_session(session_id)
        .ok_or_else(|| HttpError::unauthorized("Session not found"))?;
    Ok(session.user())
}

fn map_admin_error(message: String) -> HttpError {
    let lower = message.to_lowercase();
    if lower.contains("not found") {
        HttpError::not_found(message)
    } else if lower.contains("already exists") {
        HttpError::Conflict(message)
    } else if lower.contains("permission") {
        HttpError::forbidden(message)
    } else {
        HttpError::bad_request(message)
    }
}

#[utoipa::path(
    get,
    operation_id = "get_v1_users",
    path = "/v1/users",
    tag = "Auth",
    responses(
        (status = 200, body = UserListResponse, description = "User list"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn list<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
) -> Result<JsonResponse<UserListResponse>, HttpError> {
    let graph_service = state.server.get_graph_service();
    let session = graph_service
        .get_session_manager()
        .find_session(session_id)
        .ok_or_else(|| HttpError::unauthorized("Session not found"))?;
    let username = session.user();
    let permission_manager = graph_service.get_permission_manager();
    if !permission_manager.is_admin(&username) {
        return Err(HttpError::forbidden("Admin permission required"));
    }
    let users: Vec<UserListItem> = graph_service
        .list_users_detailed()
        .into_iter()
        .map(|detail| UserListItem {
            username: detail.username,
            role: detail.role,
            status: Some(detail.status),
            last_active: detail.last_active,
        })
        .collect();
    Ok(JsonResponse(UserListResponse { users }))
}

#[utoipa::path(
    post,
    operation_id = "post_v1_users",
    path = "/v1/users",
    tag = "Auth",
    request_body = CreateUserRequest,
    responses(
        (status = 201, description = "User created"),
        (status = 400, description = "Bad request"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 409, description = "Conflict"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn create<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Json(request): Json<CreateUserRequest>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_create_user(&caller, &request.username, &request.password)
        .map_err(map_admin_error)?;
    Ok(StatusCode::CREATED)
}

#[utoipa::path(
    post,
    operation_id = "post_v1_users_name_password",
    path = "/v1/users/{name}/password",
    tag = "Auth",
    params(("name" = String, Path, description = "Username")),
    request_body = ResetPasswordRequest,
    responses(
        (status = 204, description = "Password reset"),
        (status = 400, description = "Bad request"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 404, description = "Not found"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn reset_password<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Path(name): Path<String>,
    Json(request): Json<ResetPasswordRequest>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_reset_password(&caller, Some(session_id), &name, &request.password)
        .await
        .map_err(map_admin_error)?;
    Ok(StatusCode::NO_CONTENT)
}

#[utoipa::path(
    post,
    operation_id = "post_v1_users_name_enable",
    path = "/v1/users/{name}/enable",
    tag = "Auth",
    params(("name" = String, Path, description = "Username")),
    responses(
        (status = 204, description = "User enabled"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 404, description = "Not found"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn enable<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Path(name): Path<String>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_set_user_enabled(&caller, Some(session_id), &name, true)
        .await
        .map_err(map_admin_error)?;
    Ok(StatusCode::NO_CONTENT)
}

#[utoipa::path(
    post,
    operation_id = "post_v1_users_name_disable",
    path = "/v1/users/{name}/disable",
    tag = "Auth",
    params(("name" = String, Path, description = "Username")),
    responses(
        (status = 204, description = "User disabled"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 404, description = "Not found"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn disable<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Path(name): Path<String>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_set_user_enabled(&caller, Some(session_id), &name, false)
        .await
        .map_err(map_admin_error)?;
    Ok(StatusCode::NO_CONTENT)
}

#[utoipa::path(
    post,
    operation_id = "post_v1_users_name_grant",
    path = "/v1/users/{name}/grant",
    tag = "Auth",
    params(("name" = String, Path, description = "Username")),
    request_body = GrantRoleRequest,
    responses(
        (status = 204, description = "Role granted"),
        (status = 400, description = "Bad request"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 404, description = "Not found"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn grant<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Path(name): Path<String>,
    Json(request): Json<GrantRoleRequest>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_grant_role(&caller, &name, &request.space, &request.role)
        .map_err(map_admin_error)?;
    Ok(StatusCode::NO_CONTENT)
}

#[utoipa::path(
    post,
    operation_id = "post_v1_users_name_revoke",
    path = "/v1/users/{name}/revoke",
    tag = "Auth",
    params(("name" = String, Path, description = "Username")),
    request_body = RevokeRoleRequest,
    responses(
        (status = 204, description = "Role revoked"),
        (status = 400, description = "Bad request"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 404, description = "Not found"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn revoke<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Path(name): Path<String>,
    Json(request): Json<RevokeRoleRequest>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_revoke_role(&caller, &name, &request.space)
        .map_err(map_admin_error)?;
    Ok(StatusCode::NO_CONTENT)
}

#[utoipa::path(
    delete,
    operation_id = "delete_v1_users_name",
    path = "/v1/users/{name}",
    tag = "Auth",
    params(("name" = String, Path, description = "Username")),
    responses(
        (status = 204, description = "User deleted"),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden"),
        (status = 404, description = "Not found"),
        (status = 500, description = "Internal error")
    )
)]
pub async fn drop_user<
    S: StorageClient
        + StorageSchemaContextOps
        + StorageSyncContextOps
        + StorageOperationContextOps
        + Clone
        + Send
        + Sync
        + 'static,
>(
    State(state): State<AppState<S>>,
    Extension(session_id): Extension<i64>,
    Path(name): Path<String>,
) -> Result<StatusCode, HttpError> {
    let caller = session_user(&state, session_id)?;
    state
        .server
        .get_graph_service()
        .admin_drop_user(&caller, &name)
        .await
        .map_err(map_admin_error)?;
    Ok(StatusCode::NO_CONTENT)
}