yorishiro-server 0.50.0

HTTP server (REST + MCP) for Yorishiro, an MCP-native knowledge store
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use yorishiro_core::error::ValidationDetail;
use yorishiro_core::models::tenancy::{self, MembershipRole};
use yorishiro_core::services::auth::{self, ApiKeyScope};
use yorishiro_core::{ResultExt, YorishiroError};

use crate::error::ApiError;
use crate::state::AppState;

/// These two endpoints are the only ones in the whole API reachable without a bearer token, by design, since their entire purpose is to hand one out.
/// They read/write `identity.users`/`identity.tenant_memberships`/`identity.invites` through `state.identity_pool` (the admin/migration-role pool) rather than `state.tenant_db`, exactly like the admin CLI, since no tenant/workspace context exists yet for RLS to scope by.
#[derive(Debug, Deserialize, ToSchema)]
pub struct SignupRequest {
    /// The plaintext token from an `admin create-invite` invitation.
    pub invite_token: String,
    pub password: String,
    pub display_name: Option<String>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct WorkspaceSummary {
    pub id: Uuid,
    pub name: String,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct SignupResponse {
    pub user_id: Uuid,
    pub email: String,
    pub tenant_id: Uuid,
    pub role: MembershipRole,
    /// The workspaces the new member can now log into.
    /// The client picks one and passes its id to `/auth/login`.
    pub workspaces: Vec<WorkspaceSummary>,
}

#[utoipa::path(
    post,
    path = "/auth/signup",
    request_body = SignupRequest,
    responses(
        (status = 201, description = "Account created from a valid invite", body = SignupResponse),
        (status = 409, description = "A user with this email already exists", body = crate::error::ApiErrorBody),
        (status = 422, description = "Invite token is invalid, expired, or already used", body = crate::error::ApiErrorBody),
        (status = 429, description = "Too many requests from this caller; retry later"),
    ),
    security(()),
    tag = "auth",
)]
pub async fn signup(
    State(state): State<AppState>,
    Json(body): Json<SignupRequest>,
) -> Result<impl IntoResponse, ApiError> {
    let invite = tenancy::redeem_invite(&state.identity_pool, &body.invite_token)
        .await?
        .ok_or_else(|| YorishiroError::ValidationFailed {
            message: "invite token is invalid, expired, or already used".into(),
            details: vec![],
            hint: "ask a tenant admin for a fresh invite".into(),
        })?;

    // create_user + add_member run in one transaction: if a request dies between the two (e.g. the connection drops), an isolated user row with no tenant membership would otherwise be unrecoverable (it can never be added to a tenant via a normal signup or `admin add-member`, both of which expect a user that doesn't already exist / already does, respectively).
    // See `create_user`'s doc comment.
    let mut tx = state.identity_pool.begin().await.internal()?;
    let user = tenancy::create_user(
        &mut *tx,
        &invite.email,
        &body.password,
        body.display_name.as_deref(),
    )
    .await?;
    tenancy::add_member(&mut *tx, invite.tenant_id, user.id, invite.role).await?;
    tx.commit().await.internal()?;

    let workspaces = tenancy::list_workspaces(&state.identity_pool, invite.tenant_id)
        .await?
        .into_iter()
        .map(|workspace| WorkspaceSummary {
            id: workspace.id,
            name: workspace.name,
        })
        .collect();

    Ok((
        StatusCode::CREATED,
        Json(SignupResponse {
            user_id: user.id,
            email: user.email,
            tenant_id: invite.tenant_id,
            role: invite.role,
            workspaces,
        }),
    ))
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct LoginRequest {
    pub email: String,
    pub password: String,
    /// Which of the account's workspaces to issue an API key for: a key is always scoped to exactly one workspace, same as one created through `admin create-api-key`.
    /// Omit this when the account can only ever log into one workspace (true by default, since `YORISHIRO_MAX_TENANTS` defaults to a single tenant with one workspace); it resolves automatically.
    /// An account with access to more than one workspace must specify which one explicitly (422 otherwise), and that refusal lists the candidates so the caller does not have to look an id up elsewhere.
    pub workspace_id: Option<Uuid>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct LoginResponse {
    /// The freshly issued API key's plaintext.
    /// Shown only in this response: only its hash is ever persisted, so it cannot be recovered afterward.
    pub api_key: String,
    pub api_key_id: Uuid,
    pub workspace_id: Uuid,
    pub scope: ApiKeyScope,
    pub user_id: Uuid,
}

#[utoipa::path(
    post,
    path = "/auth/login",
    request_body = LoginRequest,
    responses(
        (status = 200, description = "A freshly issued API key, scoped to the caller's membership role", body = LoginResponse),
        (status = 401, description = "Invalid email or password", body = crate::error::ApiErrorBody),
        (status = 403, description = "Not a member of this workspace's tenant, or of any tenant at all", body = crate::error::ApiErrorBody),
        (status = 404, description = "Workspace not found", body = crate::error::ApiErrorBody),
        (status = 422, description = "workspace_id omitted, and the account can reach more than one workspace. `details` carries one entry per candidate: `field` is the workspace id and `problem` its name, so a client can offer a picker", body = crate::error::ApiErrorBody),
        (status = 429, description = "Too many requests from this caller; retry later"),
    ),
    security(()),
    tag = "auth",
)]
pub async fn login(
    State(state): State<AppState>,
    Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> {
    // Credentials are checked before the workspace is looked up, so a request with a bad password never reveals whether `workspace_id` exists.
    let user = tenancy::verify_login(&state.identity_pool, &body.email, &body.password)
        .await?
        .ok_or(YorishiroError::Unauthenticated)?;

    let workspace = match body.workspace_id {
        Some(workspace_id) => tenancy::get_workspace(&state.identity_pool, workspace_id).await?,
        // A deployment has exactly one workspace by default (see YORISHIRO_MAX_TENANTS), so resolving it automatically here means the login form never needs to ask for a workspace id in the common case.
        None => {
            let mut workspaces =
                tenancy::list_workspaces_for_user(&state.identity_pool, user.id).await?;
            match workspaces.len() {
                1 => workspaces.pop().expect("len() == 1 checked above"),
                0 => {
                    return Err(YorishiroError::ScopeInsufficient {
                        message: "this account is not a member of any tenant".into(),
                        hint: "ask a tenant admin to add you as a member first".into(),
                    }
                    .into());
                }
                _ => {
                    // The candidates go in `details`, one per workspace, so a client can offer a
                    // picker instead of asking the operator to find an id elsewhere. This is the
                    // only response that knows the list: the account is authenticated by now, and
                    // every workspace named here is one it is already a member of.
                    return Err(YorishiroError::ValidationFailed {
                        message: "this account has access to more than one workspace".into(),
                        details: workspaces
                            .into_iter()
                            .map(|w| ValidationDetail {
                                field: w.id.to_string(),
                                problem: w.name,
                            })
                            .collect(),
                        hint: "specify workspace_id explicitly".into(),
                    }
                    .into());
                }
            }
        }
    };

    let role = tenancy::get_membership_role(&state.identity_pool, workspace.tenant_id, user.id)
        .await?
        .ok_or_else(|| YorishiroError::ScopeInsufficient {
            message: "this account is not a member of the tenant that owns this workspace".into(),
            hint: "ask a tenant admin to add you as a member first".into(),
        })?;

    let mut conn = state.identity_pool.acquire().await.internal()?;
    let created =
        auth::create_api_key(&mut conn, workspace.id, role.max_scope(), Some(user.id)).await?;

    Ok(Json(LoginResponse {
        api_key: created.plaintext,
        api_key_id: created.id,
        workspace_id: created.workspace_id,
        scope: created.scope,
        user_id: user.id,
    }))
}

#[cfg(test)]
#[path = "../../../tests/http/controllers/identity.rs"]
mod tests;