openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
use ::http::{Method, StatusCode};
use openauth_core::api::{create_auth_endpoint, AsyncAuthEndpoint};
use openauth_core::error::OpenAuthError;
use serde::Deserialize;

use crate::organization::additional_fields;
use crate::organization::hooks::{
    AfterCreateTeam, AfterDeleteTeam, AfterUpdateTeam, BeforeCreateTeam, BeforeDeleteTeam,
    BeforeUpdateTeam, TeamHookData,
};
use crate::organization::http;
use crate::organization::models::Team;
use crate::organization::options::OrganizationOptions;
use crate::organization::permissions::{has_permission, OrganizationPermission};
use crate::organization::store::OrganizationStore;

pub fn endpoints(options: OrganizationOptions) -> Vec<AsyncAuthEndpoint> {
    if !options.teams.enabled {
        return Vec::new();
    }
    vec![
        create_team(options.clone()),
        remove_team(options.clone()),
        update_team(options.clone()),
        set_active_team(options.clone()),
        super::team_members::add_team_member(options.clone()),
        super::team_members::remove_team_member(options),
    ]
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TeamBody {
    name: String,
    #[serde(default)]
    organization_id: Option<String>,
}

fn create_team(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/create-team",
        Method::POST,
        super::metadata::options(
            "organizationCreateTeam",
            vec![
                super::metadata::string("name"),
                super::metadata::optional_string("organizationId"),
            ],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let body: serde_json::Value = http::body(&request)?;
                let input: TeamBody =
                    serde_json::from_value(body.clone()).map_err(json_body_error)?;
                let additional_fields = additional_fields::create_values(
                    &options.schema.team.additional_fields,
                    body.as_object().ok_or_else(|| {
                        OpenAuthError::Api("request body must be an object".to_owned())
                    })?,
                )?;
                if input.name.trim().is_empty() {
                    return http::error(
                        StatusCode::BAD_REQUEST,
                        "INVALID_REQUEST_BODY",
                        "Invalid request body",
                    );
                }
                let Some(organization_id) = super::resolve_organization_id(
                    input.organization_id,
                    session.active_organization_id.as_deref(),
                ) else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "NO_ACTIVE_ORGANIZATION",
                    );
                };
                let actor = require_member(&store, &organization_id, &session.user.id).await?;
                if !has_permission(&actor.role, &options, OrganizationPermission::TeamCreate) {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
                    );
                }
                if let Some(max) = options.teams.maximum_teams {
                    if store.teams_for_organization(&organization_id).await?.len() >= max {
                        return http::organization_error(
                            StatusCode::BAD_REQUEST,
                            "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS",
                        );
                    }
                }
                let Some(organization) = store.organization_by_id(&organization_id).await? else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "ORGANIZATION_NOT_FOUND",
                    );
                };
                let mut team_data = TeamHookData {
                    organization_id: organization_id.clone(),
                    name: input.name.trim().to_owned(),
                };
                if let Some(hook) = &options.hooks.before_create_team {
                    team_data = hook(&BeforeCreateTeam {
                        organization: organization.clone(),
                        team: team_data,
                        user: session.user.clone(),
                    })?;
                }
                if team_data.organization_id != organization_id || team_data.name.trim().is_empty()
                {
                    return http::error(
                        StatusCode::BAD_REQUEST,
                        "INVALID_REQUEST_BODY",
                        "Invalid request body",
                    );
                }
                let mut team = store
                    .create_team(&organization_id, team_data.name.trim(), additional_fields)
                    .await?;
                retain_returned_team_fields(&mut team, &options);
                store
                    .create_team_member(
                        &team.id,
                        &session.user.id,
                        openauth_core::db::DbRecord::new(),
                    )
                    .await?;
                if let Some(hook) = &options.hooks.after_create_team {
                    hook(&AfterCreateTeam {
                        organization,
                        team: team.clone(),
                        user: session.user,
                    })?;
                }
                http::json(StatusCode::OK, &team)
            })
        },
    )
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TeamIdBody {
    team_id: String,
    #[serde(default)]
    organization_id: Option<String>,
}

fn remove_team(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/remove-team",
        Method::POST,
        super::metadata::options(
            "organizationRemoveTeam",
            vec![
                super::metadata::string("teamId"),
                super::metadata::optional_string("organizationId"),
            ],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let input: TeamIdBody = http::body(&request)?;
                let Some(team) = store.team_by_id(&input.team_id).await? else {
                    return http::organization_error(StatusCode::BAD_REQUEST, "TEAM_NOT_FOUND");
                };
                let organization_id = input
                    .organization_id
                    .unwrap_or_else(|| team.organization_id.clone());
                if team.organization_id != organization_id {
                    return http::organization_error(StatusCode::BAD_REQUEST, "TEAM_NOT_FOUND");
                }
                let actor = require_member(&store, &organization_id, &session.user.id).await?;
                if !has_permission(&actor.role, &options, OrganizationPermission::TeamDelete) {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM",
                    );
                }
                if !options.teams.allow_removing_all_teams
                    && store.teams_for_organization(&organization_id).await?.len() <= 1
                {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "UNABLE_TO_REMOVE_LAST_TEAM",
                    );
                }
                let Some(organization) = store.organization_by_id(&organization_id).await? else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "ORGANIZATION_NOT_FOUND",
                    );
                };
                if let Some(hook) = &options.hooks.before_delete_team {
                    hook(&BeforeDeleteTeam {
                        organization: organization.clone(),
                        team: team.clone(),
                        user: session.user.clone(),
                    })?;
                }
                let cookies = if session.active_team_id.as_deref() == Some(&team.id) {
                    store.set_active_team(&session.session.token, None).await?;
                    http::refreshed_session_cookies(context, &session.session, &session.user)?
                } else {
                    Vec::new()
                };
                store.delete_team(&team.id).await?;
                if let Some(hook) = &options.hooks.after_delete_team {
                    hook(&AfterDeleteTeam {
                        organization,
                        team: team.clone(),
                        user: session.user,
                    })?;
                }
                http::json_with_cookies(
                    StatusCode::OK,
                    &serde_json::json!({ "team": team }),
                    cookies,
                )
            })
        },
    )
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateTeamBody {
    team_id: String,
    name: String,
}

fn update_team(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/update-team",
        Method::POST,
        super::metadata::options(
            "organizationUpdateTeam",
            vec![
                super::metadata::string("teamId"),
                super::metadata::string("name"),
            ],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let body: serde_json::Value = http::body(&request)?;
                let input: UpdateTeamBody =
                    serde_json::from_value(body.clone()).map_err(json_body_error)?;
                let additional_fields = additional_fields::update_values(
                    &options.schema.team.additional_fields,
                    body.as_object().ok_or_else(|| {
                        OpenAuthError::Api("request body must be an object".to_owned())
                    })?,
                )?;
                let Some(team) = store.team_by_id(&input.team_id).await? else {
                    return http::organization_error(StatusCode::BAD_REQUEST, "TEAM_NOT_FOUND");
                };
                let actor = require_member(&store, &team.organization_id, &session.user.id).await?;
                if !has_permission(&actor.role, &options, OrganizationPermission::TeamUpdate) {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM",
                    );
                }
                let Some(organization) = store.organization_by_id(&team.organization_id).await?
                else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "ORGANIZATION_NOT_FOUND",
                    );
                };
                let mut updates = TeamHookData {
                    organization_id: team.organization_id.clone(),
                    name: input.name.trim().to_owned(),
                };
                if let Some(hook) = &options.hooks.before_update_team {
                    updates = hook(&BeforeUpdateTeam {
                        organization: organization.clone(),
                        team: team.clone(),
                        updates,
                        user: session.user.clone(),
                    })?;
                }
                if updates.organization_id != team.organization_id || updates.name.trim().is_empty()
                {
                    return http::error(
                        StatusCode::BAD_REQUEST,
                        "INVALID_REQUEST_BODY",
                        "Invalid request body",
                    );
                }
                let mut updated = store
                    .update_team(&team.id, updates.name.trim(), additional_fields)
                    .await?;
                if let Some(team) = &mut updated {
                    retain_returned_team_fields(team, &options);
                }
                if let Some(hook) = &options.hooks.after_update_team {
                    hook(&AfterUpdateTeam {
                        organization,
                        team: updated.clone(),
                        user: session.user,
                    })?;
                }
                http::json(StatusCode::OK, &updated)
            })
        },
    )
}

fn set_active_team(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/set-active-team",
        Method::POST,
        super::metadata::options(
            "organizationSetActiveTeam",
            vec![super::metadata::string("teamId")],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let input: TeamIdBody = http::body(&request)?;
                let Some(team) = store.team_by_id(&input.team_id).await? else {
                    return http::organization_error(StatusCode::BAD_REQUEST, "TEAM_NOT_FOUND");
                };
                require_member(&store, &team.organization_id, &session.user.id).await?;
                if store
                    .team_member(&team.id, &session.user.id)
                    .await?
                    .is_none()
                {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "USER_IS_NOT_A_MEMBER_OF_THE_TEAM",
                    );
                }
                store
                    .set_active_team(&session.session.token, Some(&team.id))
                    .await?;
                let mut team = team;
                retain_returned_team_fields(&mut team, &options);
                http::json_with_cookies(
                    StatusCode::OK,
                    &team,
                    http::refreshed_session_cookies(context, &session.session, &session.user)?,
                )
            })
        },
    )
}

pub(super) fn retain_returned_team_fields(team: &mut Team, options: &OrganizationOptions) {
    additional_fields::retain_returned(
        &mut team.additional_fields,
        &options.schema.team.additional_fields,
    );
}

fn json_body_error(error: serde_json::Error) -> OpenAuthError {
    OpenAuthError::Api(error.to_string())
}

async fn require_session(
    context: &openauth_core::context::AuthContext,
    request: &openauth_core::api::ApiRequest,
    store: &OrganizationStore<'_>,
) -> Result<http::CurrentSession, openauth_core::error::OpenAuthError> {
    http::current_session(context, request, store)
        .await?
        .ok_or_else(|| openauth_core::error::OpenAuthError::Api("Unauthorized".to_owned()))
}

async fn require_member(
    store: &OrganizationStore<'_>,
    organization_id: &str,
    user_id: &str,
) -> Result<crate::organization::Member, openauth_core::error::OpenAuthError> {
    store
        .member_by_org_user(organization_id, user_id)
        .await?
        .ok_or_else(|| openauth_core::error::OpenAuthError::Api("Member not found".to_owned()))
}