use axum::{
Json,
extract::FromRequestParts,
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
};
use fraiseql_core::security::SecurityContext;
use crate::extractors::OptionalSecurityContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObserverScope {
Operator,
Organisation(i64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeRefusal {
Unauthenticated,
TenantIsNotAnOrganisation,
OperatorOnly,
}
impl ObserverScope {
pub fn of(context: Option<&SecurityContext>) -> Result<Self, ScopeRefusal> {
let context = context.ok_or(ScopeRefusal::Unauthenticated)?;
match context.tenant_id.as_ref() {
None => Ok(Self::Operator),
Some(tenant) => tenant
.as_str()
.parse::<i64>()
.map(Self::Organisation)
.map_err(|_| ScopeRefusal::TenantIsNotAnOrganisation),
}
}
#[must_use]
pub const fn organisation(self) -> Option<i64> {
match self {
Self::Operator => None,
Self::Organisation(org) => Some(org),
}
}
pub(crate) fn push_visible(self, query: &mut sqlx::QueryBuilder<'_, sqlx::Postgres>) {
if let Self::Organisation(org) = self {
query
.push(" AND (fk_customer_org IS NULL OR fk_customer_org = ")
.push_bind(org)
.push(")");
}
}
pub(crate) fn push_owned(
self,
query: &mut sqlx::QueryBuilder<'_, sqlx::Postgres>,
observer_key: &str,
) {
if let Self::Organisation(org) = self {
query
.push(" AND ")
.push(observer_key)
.push(" IN (SELECT pk_observer FROM tb_observer WHERE fk_customer_org = ")
.push_bind(org)
.push(")");
}
}
}
impl IntoResponse for ScopeRefusal {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::Unauthenticated => (StatusCode::UNAUTHORIZED, "Authentication required"),
Self::TenantIsNotAnOrganisation => (
StatusCode::FORBIDDEN,
"The observer admin API scopes by a numeric organisation id, and this token's \
tenant is not one",
),
Self::OperatorOnly => (
StatusCode::FORBIDDEN,
"This endpoint spans every tenant and refuses a token scoped to one",
),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
impl<S> FromRequestParts<S> for ObserverScope
where
S: Send + Sync + 'static,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let OptionalSecurityContext(context) =
OptionalSecurityContext::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
Self::of(context.as_ref()).map_err(IntoResponse::into_response)
}
}
#[derive(Debug, Clone, Copy)]
pub struct OperatorOnly;
impl<S> FromRequestParts<S> for OperatorOnly
where
S: Send + Sync + 'static,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let OptionalSecurityContext(context) =
OptionalSecurityContext::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let context = context.ok_or_else(|| ScopeRefusal::Unauthenticated.into_response())?;
if context.tenant_id.is_some() {
return Err(ScopeRefusal::OperatorOnly.into_response());
}
Ok(Self)
}
}
#[cfg(test)]
mod tests;