use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
security::{ActorType, AuthenticatedUser, derive_actor},
types::{TenantId, UserId},
};
fn roles_from_claims(extra_claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
let mut roles = Vec::new();
if let Some(serde_json::Value::String(role)) = extra_claims.get("role") {
roles.push(role.clone());
}
for key in ["roles", "fraiseql_roles"] {
if let Some(serde_json::Value::Array(values)) = extra_claims.get(key) {
roles.extend(values.iter().filter_map(|v| match v {
serde_json::Value::String(role) => Some(role.clone()),
_ => None,
}));
}
}
roles.sort();
roles.dedup();
roles
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityContext {
pub user_id: UserId,
pub roles: Vec<String>,
pub tenant_id: Option<TenantId>,
pub scopes: Vec<String>,
pub attributes: HashMap<String, serde_json::Value>,
pub request_id: String,
pub ip_address: Option<String>,
pub authenticated_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub issuer: Option<String>,
pub audience: Option<String>,
pub email: Option<String>,
pub display_name: Option<String>,
}
impl SecurityContext {
pub const ACTING_FOR_ATTRIBUTE: &'static str = "fraiseql.acting_for";
pub const ACTOR_TYPE_ATTRIBUTE: &'static str = "fraiseql.actor_type";
pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
#[must_use]
pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
let (actor_type, acting_for) =
derive_actor(user.user_id.as_str(), &user.scopes, &user.extra_claims);
SecurityContext {
user_id: user.user_id.clone(),
roles: roles_from_claims(&user.extra_claims),
tenant_id: None,
scopes: user.scopes.clone(),
attributes: HashMap::new(),
request_id,
ip_address: None,
authenticated_at: Utc::now(),
expires_at: user.expires_at,
issuer: None,
audience: None,
email: user.email.clone(),
display_name: user.display_name.clone(),
}
.with_actor_type(actor_type)
.with_acting_for(acting_for)
}
#[must_use]
pub fn system_job(
job_id: impl Into<String>,
request_id: impl Into<String>,
roles: Vec<String>,
scopes: Vec<String>,
tenant: Option<TenantId>,
) -> Self {
let now = Utc::now();
SecurityContext {
user_id: UserId(format!("system_job:{}", job_id.into())),
roles,
tenant_id: tenant,
scopes,
attributes: HashMap::new(),
request_id: request_id.into(),
ip_address: None,
authenticated_at: now,
expires_at: now + chrono::Duration::hours(24),
issuer: None,
audience: None,
email: None,
display_name: None,
}
.with_actor_type(ActorType::SystemJob)
}
#[must_use]
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
#[must_use]
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| {
if s == scope {
return true;
}
if s.ends_with(':') {
scope.starts_with(s)
} else if s.ends_with('*') {
let prefix = &s[..s.len() - 1];
scope.starts_with(prefix)
} else {
false
}
})
}
#[must_use]
pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
self.attributes.get(key)
}
#[must_use]
pub fn trace_id(&self) -> Option<&str> {
self.attributes
.get(Self::TRACE_ID_ATTRIBUTE)
.and_then(serde_json::Value::as_str)
}
#[must_use]
pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
self.attributes.insert(
Self::TRACE_ID_ATTRIBUTE.to_string(),
serde_json::Value::String(trace_id.into()),
);
self
}
#[must_use]
pub fn trace_context(&self) -> Option<&serde_json::Value> {
self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
}
#[must_use]
pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
self
}
#[must_use]
pub fn actor_type(&self) -> ActorType {
self.attributes
.get(Self::ACTOR_TYPE_ATTRIBUTE)
.and_then(serde_json::Value::as_str)
.and_then(ActorType::from_token)
.unwrap_or_default()
}
#[must_use]
pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
self.attributes.insert(
Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
serde_json::Value::String(actor_type.as_str().to_string()),
);
self
}
#[must_use]
pub fn acting_for(&self) -> Option<Uuid> {
self.attributes
.get(Self::ACTING_FOR_ATTRIBUTE)
.and_then(serde_json::Value::as_str)
.and_then(|s| Uuid::parse_str(s).ok())
}
#[must_use]
pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
match acting_for {
Some(uuid) => {
self.attributes.insert(
Self::ACTING_FOR_ATTRIBUTE.to_string(),
serde_json::Value::String(uuid.to_string()),
);
},
None => {
self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
},
}
self
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.expires_at <= Utc::now()
}
#[must_use]
pub fn ttl_secs(&self) -> i64 {
(self.expires_at - Utc::now()).num_seconds()
}
#[must_use]
pub fn is_admin(&self) -> bool {
self.has_role("admin")
}
#[must_use]
pub const fn is_multi_tenant(&self) -> bool {
self.tenant_id.is_some()
}
#[must_use]
pub fn with_role(mut self, role: String) -> Self {
self.roles.push(role);
self
}
#[must_use]
pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
self.scopes = scopes;
self
}
pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
#[must_use]
pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
self.attributes.insert(key, value);
self
}
#[must_use]
pub fn can_access_scope(
&self,
security_config: &crate::schema::SecurityConfig,
required_scope: &str,
) -> bool {
self.roles
.iter()
.any(|role_name| security_config.role_has_scope(role_name, required_scope))
}
}
impl std::fmt::Display for SecurityContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
self.user_id,
self.roles,
self.scopes.len(),
self.tenant_id
)
}
}