use std::{collections::HashMap, sync::Arc};
pub use fraiseql_core::security::EnrichmentOutcome;
use fraiseql_core::security::{
ActorType, BoxFuture, ENRICHED_NAMESPACE_PREFIX, EnrichmentMark, IdentityEnricher,
SecurityContext,
};
use super::{failure::IdentityResolution, resolver::IdentityResolver};
use crate::api::rbac_management::assigned_roles::AssignedRoles;
#[derive(Clone, Default)]
pub struct RequestIdentity {
resolver: Option<Arc<IdentityResolver>>,
assigned_roles: Option<Arc<AssignedRoles>>,
}
impl RequestIdentity {
#[must_use]
pub fn with_resolver(mut self, resolver: Arc<IdentityResolver>) -> Self {
self.resolver = Some(resolver);
self
}
#[must_use]
pub fn with_assigned_roles(mut self, assigned_roles: Arc<AssignedRoles>) -> Self {
self.assigned_roles = Some(assigned_roles);
self
}
#[must_use]
pub const fn resolver(&self) -> Option<&Arc<IdentityResolver>> {
self.resolver.as_ref()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.resolver.is_none() && self.assigned_roles.is_none()
}
}
async fn add_assigned_roles(
assigned_roles: &AssignedRoles,
ctx: &mut SecurityContext,
) -> Result<(), EnrichmentOutcome> {
if ctx.actor_type() != ActorType::HumanUser {
return Ok(());
}
let tenant = ctx.tenant_id.as_ref().map(|tenant| tenant.as_str());
match assigned_roles.of(&ctx.user_id.0, tenant).await {
Ok(roles) => {
for role in roles {
if !ctx.roles.contains(&role) {
ctx.roles.push(role);
}
}
Ok(())
},
Err(e) => {
tracing::warn!(sub = %ctx.user_id.0, error = %e, "assigned roles could not be read");
Err(EnrichmentOutcome::Unavailable)
},
}
}
pub async fn enrich_security_context(
resolver: &IdentityResolver,
ctx: &mut SecurityContext,
) -> EnrichmentOutcome {
if ctx.attributes.keys().any(|k| k.starts_with(ENRICHED_NAMESPACE_PREFIX)) {
ctx.mark_enrichment(EnrichmentMark::Resolved);
return EnrichmentOutcome::Proceed;
}
let sub = ctx.user_id.0.clone();
let claims = claims_for_binding(ctx, resolver.tenant_claim());
match resolver.resolve(&sub, &claims).await {
IdentityResolution::Resolved(fields) => {
for (field, value) in fields {
ctx.attributes.insert(format!("{ENRICHED_NAMESPACE_PREFIX}{field}"), value);
}
ctx.mark_enrichment(EnrichmentMark::Resolved);
EnrichmentOutcome::Proceed
},
IdentityResolution::Denied(_) => EnrichmentOutcome::Denied,
IdentityResolution::Unavailable(_) => EnrichmentOutcome::Unavailable,
}
}
pub async fn resolve_request_identity(
identity: &RequestIdentity,
security_context: Option<&mut SecurityContext>,
) -> EnrichmentOutcome {
let Some(ctx) = security_context else {
return EnrichmentOutcome::Proceed;
};
if let Some(assigned_roles) = identity.assigned_roles.as_deref() {
if let Err(outcome) = add_assigned_roles(assigned_roles, ctx).await {
return outcome;
}
}
match identity.resolver.as_deref() {
Some(resolver) => enrich_security_context(resolver, ctx).await,
None => EnrichmentOutcome::Proceed,
}
}
fn claims_for_binding(
ctx: &SecurityContext,
tenant_claim: &str,
) -> HashMap<String, serde_json::Value> {
let mut claims = ctx.attributes.clone();
for name in ["sub", "email", "name", "display_name", "iss", tenant_claim] {
if let Some(value) = ctx.jwt_claim(name, tenant_claim) {
claims.entry(name.to_owned()).or_insert(value);
}
}
let snapshot: serde_json::Map<String, serde_json::Value> =
claims.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
claims.entry("claims".to_owned()).or_insert(serde_json::Value::Object(snapshot));
claims
}
impl IdentityEnricher for RequestIdentity {
fn enrich<'a>(&'a self, ctx: &'a mut SecurityContext) -> BoxFuture<'a, EnrichmentOutcome> {
Box::pin(resolve_request_identity(self, Some(ctx)))
}
}