liminal-server 0.24.0

Standalone server for the liminal messaging bus
Documentation
//! Identity claims are checked against the admitted Lys holder, never a seat file.

use crate::auth_lys::session::LysSession;
use crate::auth_lys::{AccessAction, AccessResource, ResourceKind};
use crate::auth_lys::{HotAction, SubjectBinding};
use crate::config::templates::is_segment;

use super::error::AgentError;

/// The name and leaf of an exact agent subject. Patterns are never identities.
#[must_use]
pub fn subject(subject: &str) -> Option<(&str, &str)> {
    let rest = subject.strip_prefix("agents.")?;
    let (name, leaf) = rest.split_once('.')?;
    (is_segment(name)
        && matches!(
            leaf,
            "inbox" | "presence" | "state" | "stream" | "plan" | "cancel"
        ))
    .then_some((name, leaf))
}

/// Whether a subject has the server as its sole writer.
#[must_use]
pub fn server_owned(value: &str) -> bool {
    subject(value).is_some_and(|(_, leaf)| matches!(leaf, "presence" | "state"))
}

/// The server's inner publish guard, including direct trait callers.
///
/// # Errors
/// A connection attempted to write retained server metadata.
pub fn refuse_server_publish(value: &str) -> Result<(), AgentError> {
    if server_owned(value) {
        Err(AgentError::ServerOwned(value.into()))
    } else {
        Ok(())
    }
}

/// Maps agent message/watch to the placed agent's right. Every other act uses
///
/// its existing subject right. The adapter's one evaluation site calls this;
/// held rights keep the adapter's existing hot-mode refusal, never approval.
///
/// # Errors
/// The verified pass's expiry, audience or rights error, unchanged.
pub fn evaluate_subject<'a>(
    pass: &'a lys_pass::VerifiedPass,
    resource: &AccessResource,
    action: AccessAction,
    now: u64,
) -> Result<lys_pass::Decision<'a>, lys_pass::Error> {
    if let Some((name, action)) = agent_target(resource, action) {
        return pass.evaluate("liminal.agent", name, action, now);
    }
    pass.evaluate(resource.kind.qualified(), &resource.id, action.name(), now)
}

/// Names the actual grant used by the alias, preserving the consumer's cause.
#[must_use]
pub fn refusal(
    resource: &AccessResource,
    action: AccessAction,
    request: &str,
    cause: crate::auth_lys::RefusalCause,
) -> crate::auth_lys::AccessDecisionError {
    let refusal = if let Some((name, action)) = agent_target(resource, action) {
        crate::auth_lys::AccessRefusal::for_target(
            "liminal",
            "liminal.agent",
            name,
            action,
            request.into(),
        )
    } else {
        crate::auth_lys::AccessRefusal::new(resource.clone(), action, request.into())
    };
    crate::auth_lys::AccessDecisionError { cause, refusal }
}

fn agent_target(resource: &AccessResource, action: AccessAction) -> Option<(&str, &'static str)> {
    if resource.kind != ResourceKind::Subject {
        return None;
    }
    let (name, leaf) = subject(&resource.id)?;
    let action = match (action, leaf) {
        (AccessAction::Publish, "inbox" | "cancel") => "message",
        (AccessAction::Subscribe, "presence" | "state" | "stream") => "watch",
        _ => return None,
    };
    Some((name, action))
}

/// Checks a tool's identity claim against the pass's own agent identity.
///
/// # Errors
/// The holder is unverified, is not an agent, or names someone else.
pub fn claim(session: Option<&dyn LysSession>, name: &str) -> Result<(), AgentError> {
    if session
        // An agent's station, or a person's own seat: the holder is exactly that name either way.
        .is_some_and(|session| {
            matches!(session.holder_kind(), Some("agent" | "person")) && session.holder() == name
        })
    {
        return Ok(());
    }
    Err(AgentError::Identity {
        holder: session.map_or("unverified", LysSession::holder).into(),
        agent: name.into(),
    })
}

/// Identity is an additional fence; passing it grants no subject right.
///
/// # Errors
/// A foreign identity claim or a write to server-owned metadata.
pub fn check(
    session: Option<&dyn LysSession>,
    action: HotAction,
    value: &str,
) -> Result<(), AgentError> {
    let Some((name, leaf)) = subject(value) else {
        if value.starts_with("agents.")
            && value.split('.').count() != 3
            && !matches!(
                value,
                "agents.session"
                    | "agents.permission"
                    | "agents.refuse_prompt"
                    | "agents.session_close"
            )
        {
            return Err(AgentError::Invalid("agent subject"));
        }
        return Ok(());
    };
    if (action == HotAction::Subscribe && leaf == "inbox")
        || (matches!(action, HotAction::Publish | HotAction::Serve)
            && !matches!(leaf, "inbox" | "cancel"))
    {
        claim(session, name)?;
    }
    if matches!(action, HotAction::Publish | HotAction::Serve) && server_owned(value) {
        return Err(AgentError::ServerOwned(value.into()));
    }
    if session.is_none() {
        return Err(AgentError::Invalid("agent subjects require a Lys pass"));
    }
    Ok(())
}

/// Decides the ordinary subject grant after the identity fence.
///
/// # Errors
/// No admitted pass, a bad binding, or the pass's own named refusal.
pub fn decide(
    session: Option<&dyn LysSession>,
    network: &str,
    action: HotAction,
    value: &str,
    at_ms: u64,
) -> Result<(), AgentError> {
    check(session, action, value)?;
    let session = session.ok_or(AgentError::Invalid("agent subjects require a Lys pass"))?;
    let binding = SubjectBinding::new(network.into(), value.into(), value.into())
        .map_err(|_| AgentError::Invalid("agent subject binding"))?;
    session
        .decide(&binding, action, at_ms / 1000)
        .map_err(|error| AgentError::Store(error.to_string()))?;
    Ok(())
}

/// Closed host services always authenticate their payload claim in the handler.
#[must_use]
pub fn is_host_route(subject: &str) -> bool {
    matches!(
        subject,
        "agents.session" | "agents.permission" | "agents.refuse_prompt" | "agents.session_close"
    )
}