kirino 0.6.2

Zero-trust authentication and authorization framework with RBAC and dynamic risk assessment.
Documentation
use anyhow::Result;
use chrono::{Duration, Utc};
use std::collections::HashSet;
use uuid::Uuid;

use super::{Session, SessionManager};
#[cfg(feature = "rbac-constraints")]
use super::validate_dsd_with_store;
#[cfg(feature = "rbac-constraints")]
use crate::rbac::constraints::store::ConstraintStore;

use crate::{
    error::KirinoError,
    rbac::{
        shared::Shared,
        store::persistence::{PersistentSessionStore, SessionRow},
        traits::{AssignmentStore, Permission, Subject},
    },
};

pub struct DbSessionManager<S, P>
where
    S: Subject,
    P: Permission,
{
    store: Shared<dyn PersistentSessionStore>,
    assignment_store: Shared<dyn AssignmentStore<S, P>>,
    #[cfg(feature = "rbac-constraints")]
    constraint_store: Option<Shared<dyn ConstraintStore>>,
    _phantom: std::marker::PhantomData<P>,
}

impl<S, P> DbSessionManager<S, P>
where
    S: Subject,
    P: Permission,
{
    pub fn new(
        store: impl PersistentSessionStore + 'static,
        assignment_store: impl AssignmentStore<S, P> + 'static,
    ) -> Self {
        Self {
            store: Shared::from_arc_unsized(std::sync::Arc::new(store)),
            assignment_store: Shared::from_arc_unsized(std::sync::Arc::new(assignment_store)),
            #[cfg(feature = "rbac-constraints")]
            constraint_store: None,
            _phantom: std::marker::PhantomData,
        }
    }

    #[cfg(feature = "rbac-constraints")]
    pub fn with_constraint_store(mut self, store: impl ConstraintStore + 'static) -> Self {
        self.constraint_store = Some(Shared::from_arc_unsized(std::sync::Arc::new(store)));
        self
    }

    pub fn assignment_store(&self) -> Shared<dyn AssignmentStore<S, P>> {
        self.assignment_store.clone()
    }

    pub async fn cleanup_expired(&self) -> Result<usize> {
        self.store.cleanup_expired().await
    }
}

#[async_trait::async_trait]
impl<S, P> SessionManager<S> for DbSessionManager<S, P>
where
    S: Subject,
    P: Permission,
{
    async fn create_session(
        &self,
        subject: &S,
        active_roles: HashSet<String>,
        ttl: Duration,
    ) -> Result<Session<S>> {
        let assigned_roles = self
            .assignment_store
            .roles_of(subject)
            .await?
            .into_iter()
            .collect::<HashSet<_>>();
        let validated_roles: HashSet<String> = active_roles
            .into_iter()
            .filter(|r| assigned_roles.contains(r))
            .collect();

        #[cfg(feature = "rbac-constraints")]
        if let Some(ref cs) = self.constraint_store {
            validate_dsd_with_store(&validated_roles, cs).await?;
        }

        let now = Utc::now();
        let session = Session {
            id: Uuid::now_v7(),
            subject: subject.clone(),
            active_roles: validated_roles.clone(),
            context: None,
            version: 0,
            created_at: now,
            expires_at: now + ttl,
        };

        let row = SessionRow {
            id: session.id,
            subject_id: subject.subject_id().to_string(),
            active_roles: validated_roles.into_iter().collect(),
            context: None,
            version: 0,
            expires_at: session.expires_at,
            created_at: now,
        };
        self.store.save_session(&row).await?;

        Ok(session)
    }

    async fn activate_role(&self, session_id: Uuid, role_name: &str) -> Result<()> {
        let row = self
            .store
            .load_session(session_id)
            .await?
            .ok_or(KirinoError::SessionNotFound)?;

        if Utc::now() > row.expires_at {
            return Err(KirinoError::SessionExpired.into());
        }

        let mut roles: HashSet<String> = row.active_roles.into_iter().collect();
        if roles.contains(role_name) {
            return Ok(());
        }

        let subject = S::try_from_subject_id(&row.subject_id).map_err(|e| {
            anyhow::anyhow!(
                "invalid subject_id '{}' in session {}: {e}",
                row.subject_id,
                session_id
            )
        })?;
        let assigned = self.assignment_store.roles_of(&subject).await?;
        let role_str = role_name.to_string();
        if !assigned.contains(&role_str) {
            return Err(KirinoError::NotFound(format!(
                "role '{role_name}' not assigned to subject"
            ))
            .into());
        }

        roles.insert(role_str);

        #[cfg(feature = "rbac-constraints")]
        if let Some(ref cs) = self.constraint_store {
            validate_dsd_with_store(&roles, cs).await?;
        }

        let roles_vec: Vec<String> = roles.into_iter().collect();
        self.store.update_roles(session_id, &roles_vec).await
    }

    async fn deactivate_role(&self, session_id: Uuid, role_name: &str) -> Result<()> {
        let row = self
            .store
            .load_session(session_id)
            .await?
            .ok_or(KirinoError::SessionNotFound)?;

        if Utc::now() > row.expires_at {
            return Err(KirinoError::SessionExpired.into());
        }

        let mut roles: HashSet<String> = row.active_roles.into_iter().collect();
        roles.remove(role_name);

        let roles_vec: Vec<String> = roles.into_iter().collect();
        self.store.update_roles(session_id, &roles_vec).await
    }

    async fn get_session(&self, session_id: Uuid) -> Result<Option<Session<S>>> {
        let row = self.store.load_session(session_id).await?;
        match row {
            Some(r) => {
                let subject = S::try_from_subject_id(&r.subject_id).map_err(|e| {
                    anyhow::anyhow!(
                        "invalid subject_id '{}' in session {}: {e}",
                        r.subject_id,
                        r.id
                    )
                })?;
                Ok(Some(Session {
                    id: r.id,
                    subject,
                    active_roles: r.active_roles.into_iter().collect(),
                    context: r.context,
                    version: r.version,
                    created_at: r.created_at,
                    expires_at: r.expires_at,
                }))
            }
            None => Ok(None),
        }
    }

    async fn destroy_session(&self, session_id: Uuid) -> Result<()> {
        self.store.delete_session(session_id).await
    }

    async fn revoke_all_for_subject(&self, subject: &S) -> Result<usize> {
        let subject_id = subject.subject_id();
        let assignments = self.assignment_store.roles_of(subject).await?;
        for role in &assignments {
            self.assignment_store.revoke_role(subject, role).await?;
        }
        Ok(assignments.len())
    }

    async fn current_version(&self, subject: &S) -> Result<u64> {
        let session = self.store.load_session_metadata(subject.subject_id()).await?;
        Ok(session.map(|s| s.version).unwrap_or(0))
    }

    async fn bump_version_for_subject(&self, subject: &S) -> Result<u64> {
        let current = self.current_version(subject).await?;
        let next = current.wrapping_add(1);
        self.store.bump_version(subject.subject_id(), next).await?;
        Ok(next)
    }
}