sockudo-core 4.6.0

Core traits, types, error handling, and configuration for Sockudo
Documentation
use crate::error::{Error, Result};
use crate::token::secure_compare;
use crate::websocket::ConnectionCapabilities;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationKind {
    Update,
    Delete,
    Append,
}

impl MutationKind {
    pub fn as_verb(self) -> &'static str {
        match self {
            Self::Update => "update",
            Self::Delete => "delete",
            Self::Append => "append",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationGrant {
    Own,
    Any,
}

#[derive(Debug, Clone)]
pub struct MutationAuthorizationRequest<'a> {
    pub channel: &'a str,
    pub kind: MutationKind,
    pub original_client_id: Option<&'a str>,
    pub actor_client_id: Option<&'a str>,
    pub capabilities: Option<&'a ConnectionCapabilities>,
    pub privileged_server: bool,
}

pub fn authorize_message_mutation(
    request: MutationAuthorizationRequest<'_>,
) -> Result<MutationGrant> {
    if request.privileged_server {
        return Ok(MutationGrant::Any);
    }

    let capabilities = request.capabilities.ok_or_else(|| {
        Error::Auth(format!(
            "Connection is not allowed to {} message on channel '{}'",
            request.kind.as_verb(),
            request.channel
        ))
    })?;

    if capabilities.allows_message_mutation_any(request.kind, request.channel) {
        return Ok(MutationGrant::Any);
    }

    if capabilities.allows_message_mutation_own(request.kind, request.channel) {
        let actor_client_id = request.actor_client_id.ok_or_else(|| {
            Error::Auth(format!(
                "Connection must have an identified client to {} own messages",
                request.kind.as_verb()
            ))
        })?;

        let original_client_id = request.original_client_id.ok_or_else(|| {
            Error::Auth(format!(
                "Cannot authorize own-scoped {} because the original message creator is unidentified",
                request.kind.as_verb()
            ))
        })?;

        if secure_compare(actor_client_id, original_client_id) {
            return Ok(MutationGrant::Own);
        }

        return Err(Error::Auth(format!(
            "Connection client '{}' is not allowed to {} message owned by '{}'",
            actor_client_id,
            request.kind.as_verb(),
            original_client_id
        )));
    }

    Err(Error::Auth(format!(
        "Connection is not allowed to {} message on channel '{}'",
        request.kind.as_verb(),
        request.channel
    )))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn caps_with_update_own() -> ConnectionCapabilities {
        ConnectionCapabilities {
            subscribe: None,
            publish: None,
            presence: None,
            message_update_own: Some(vec!["chat:*".to_string()]),
            message_update_any: None,
            message_delete_own: None,
            message_delete_any: None,
            message_append_own: None,
            message_append_any: None,
            ..Default::default()
        }
    }

    fn caps_with_delete_any() -> ConnectionCapabilities {
        ConnectionCapabilities {
            subscribe: None,
            publish: None,
            presence: None,
            message_update_own: None,
            message_update_any: None,
            message_delete_own: None,
            message_delete_any: Some(vec!["chat:*".to_string()]),
            message_append_own: None,
            message_append_any: None,
            ..Default::default()
        }
    }

    fn caps_with_append_own() -> ConnectionCapabilities {
        ConnectionCapabilities {
            subscribe: None,
            publish: None,
            presence: None,
            message_update_own: None,
            message_update_any: None,
            message_delete_own: None,
            message_delete_any: None,
            message_append_own: Some(vec!["chat:*".to_string()]),
            message_append_any: None,
            ..Default::default()
        }
    }

    #[test]
    fn privileged_server_grants_any_scope() {
        let grant = authorize_message_mutation(MutationAuthorizationRequest {
            channel: "chat:room-1",
            kind: MutationKind::Update,
            original_client_id: Some("user-1"),
            actor_client_id: None,
            capabilities: None,
            privileged_server: true,
        })
        .unwrap();

        assert_eq!(grant, MutationGrant::Any);
    }

    #[test]
    fn own_update_succeeds_for_matching_identified_actor() {
        let grant = authorize_message_mutation(MutationAuthorizationRequest {
            channel: "chat:room-1",
            kind: MutationKind::Update,
            original_client_id: Some("user-1"),
            actor_client_id: Some("user-1"),
            capabilities: Some(&caps_with_update_own()),
            privileged_server: false,
        })
        .unwrap();

        assert_eq!(grant, MutationGrant::Own);
    }

    #[test]
    fn own_update_fails_for_mismatched_actor() {
        let err = authorize_message_mutation(MutationAuthorizationRequest {
            channel: "chat:room-1",
            kind: MutationKind::Update,
            original_client_id: Some("user-1"),
            actor_client_id: Some("user-2"),
            capabilities: Some(&caps_with_update_own()),
            privileged_server: false,
        })
        .unwrap_err();

        assert!(err.to_string().contains("is not allowed to update"));
    }

    #[test]
    fn own_append_fails_without_identified_actor() {
        let err = authorize_message_mutation(MutationAuthorizationRequest {
            channel: "chat:room-1",
            kind: MutationKind::Append,
            original_client_id: Some("user-1"),
            actor_client_id: None,
            capabilities: Some(&caps_with_append_own()),
            privileged_server: false,
        })
        .unwrap_err();

        assert!(err.to_string().contains("must have an identified client"));
    }

    #[test]
    fn any_delete_succeeds_without_owner_match() {
        let grant = authorize_message_mutation(MutationAuthorizationRequest {
            channel: "chat:room-1",
            kind: MutationKind::Delete,
            original_client_id: Some("user-1"),
            actor_client_id: Some("user-2"),
            capabilities: Some(&caps_with_delete_any()),
            privileged_server: false,
        })
        .unwrap();

        assert_eq!(grant, MutationGrant::Any);
    }

    #[test]
    fn mutation_without_capability_is_denied() {
        let err = authorize_message_mutation(MutationAuthorizationRequest {
            channel: "chat:room-1",
            kind: MutationKind::Delete,
            original_client_id: Some("user-1"),
            actor_client_id: Some("user-1"),
            capabilities: Some(&ConnectionCapabilities::default()),
            privileged_server: false,
        })
        .unwrap_err();

        assert!(err.to_string().contains("not allowed to delete"));
    }
}