identity.rs 205 lignes · 7249 octets
//! Unified actor abstraction (RFC 0008).
//!
//! `Identity` is the type that services should accept everywhere a
//! handler used to take `Uuid /* users.id */`. Discriminating local
//! and remote callers at the type level prevents the confused-deputy
//! hazard: an authoriser cannot accidentally treat a federated subject
//! as if it had local roles.
//!
//! Always compiled in core: `Identity` and `IdentityResolver` are
//! generic abstractions that do not depend on `rustwarden-fediverse`.
//! Consumers can use them whether or not federation is enabled —
//! federation simply implements `IdentityResolver` to populate the
//! `Remote` variant.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::auth::jwt::Claims;
use crate::error::AppError;

/// Universally unique reference to an actor known to Rustwarden.
///
/// The `kind` discriminator goes on the wire via the JSON tag form,
/// e.g. `{"kind":"local","id":"<uuid>"}`. Variant data is just the
/// UUID — services lookup metadata (username, display name) on demand
/// through [`IdentityResolver`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "id")]
pub enum Identity {
    /// `users.id` — a locally-registered account.
    Local(Uuid),
    /// `remote_identities.id` — a federated user that authenticated
    /// via the AP-native flow of RFC 0006.
    Remote(Uuid),
}

impl Identity {
    /// Returns true when this identity is local.
    pub fn is_local(&self) -> bool {
        matches!(self, Self::Local(_))
    }

    /// Returns true when this identity is remote.
    pub fn is_remote(&self) -> bool {
        matches!(self, Self::Remote(_))
    }

    /// Returns the underlying UUID regardless of variant. Use
    /// [`Self::as_local`] / [`Self::as_remote`] when you must
    /// distinguish.
    pub fn id(&self) -> Uuid {
        match self {
            Self::Local(id) | Self::Remote(id) => *id,
        }
    }

    /// `Some(uuid)` only when `self` is `Local`.
    pub fn as_local(&self) -> Option<Uuid> {
        match self {
            Self::Local(id) => Some(*id),
            Self::Remote(_) => None,
        }
    }

    /// `Some(uuid)` only when `self` is `Remote`.
    pub fn as_remote(&self) -> Option<Uuid> {
        match self {
            Self::Remote(id) => Some(*id),
            Self::Local(_) => None,
        }
    }

    /// Build an `Identity` from RFC 0007 [`Claims`]. Falls back to
    /// `Local(claims.sub)` when the claims look local; uses
    /// [`Claims::remote_identity_id`] (which already guards against
    /// the confused-deputy case) for remote tokens.
    pub fn from_claims(claims: &Claims) -> Self {
        match claims.remote_identity_id() {
            Some(remote_id) => Self::Remote(remote_id),
            None => Self::Local(claims.sub),
        }
    }
}

/// Look-up facade returning a unified [`Identity`] regardless of the
/// underlying table. Implementations are responsible for translating
/// from raw IDs to the correct variant; services that only need to
/// check existence can use [`Self::resolve_local`] /
/// [`Self::resolve_remote`] for the bare paths.
#[async_trait]
pub trait IdentityResolver: Send + Sync {
    /// Returns `Some(Identity::Local(user_id))` if the row exists in
    /// `users`, `None` otherwise.
    async fn resolve_local(&self, user_id: Uuid) -> Result<Option<Identity>, AppError>;

    /// Returns `Some(Identity::Remote(remote_identity_id))` if the row
    /// exists in `remote_identities`, `None` otherwise.
    async fn resolve_remote(&self, remote_identity_id: Uuid) -> Result<Option<Identity>, AppError>;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::jwt::IdentityKind;
    use chrono::Utc;

    fn make_claims(kind: IdentityKind, remote_id: Option<Uuid>) -> Claims {
        Claims {
            sub: Uuid::new_v4(),
            jti: Uuid::new_v4().to_string(),
            aud: "rustwarden".into(),
            username: "alice".into(),
            roles: vec![],
            permissions: vec![],
            exp: Utc::now().timestamp() + 900,
            iat: Utc::now().timestamp(),
            iss: "rustwarden".into(),
            identity_kind: kind,
            remote_id,
        }
    }

    #[test]
    fn identity_local_helpers() {
        let id = Uuid::new_v4();
        let identity = Identity::Local(id);
        assert!(identity.is_local());
        assert!(!identity.is_remote());
        assert_eq!(identity.id(), id);
        assert_eq!(identity.as_local(), Some(id));
        assert_eq!(identity.as_remote(), None);
    }

    #[test]
    fn identity_remote_helpers() {
        let id = Uuid::new_v4();
        let identity = Identity::Remote(id);
        assert!(identity.is_remote());
        assert!(!identity.is_local());
        assert_eq!(identity.id(), id);
        assert_eq!(identity.as_remote(), Some(id));
        assert_eq!(identity.as_local(), None);
    }

    #[test]
    fn identity_serialises_with_kind_and_id() {
        let id = Uuid::new_v4();
        let local = Identity::Local(id);
        let value = serde_json::to_value(local).unwrap();
        assert_eq!(value["kind"], serde_json::json!("local"));
        assert_eq!(value["id"], serde_json::json!(id));
        let round_trip: Identity = serde_json::from_value(value).unwrap();
        assert_eq!(round_trip, local);
    }

    #[test]
    fn identity_from_claims_local() {
        let claims = make_claims(IdentityKind::Local, None);
        let identity = Identity::from_claims(&claims);
        assert_eq!(identity, Identity::Local(claims.sub));
    }

    #[test]
    fn identity_from_claims_remote() {
        let remote_id = Uuid::new_v4();
        let claims = make_claims(IdentityKind::Remote, Some(remote_id));
        let identity = Identity::from_claims(&claims);
        assert_eq!(identity, Identity::Remote(remote_id));
    }

    #[test]
    fn identity_from_claims_remote_without_remote_id_falls_back_to_local() {
        // Confused-deputy guard: a Remote token without a remote_id is
        // treated as local since `Claims::remote_identity_id` returns
        // None in that case. RFC 0007 / RFC 0008 ensure we never
        // promote silently.
        let claims = make_claims(IdentityKind::Remote, None);
        let identity = Identity::from_claims(&claims);
        // remote_identity_id() returns None because remote_id is None,
        // so we fall through to Local(sub).
        assert_eq!(identity, Identity::Local(claims.sub));
    }

    #[test]
    fn identity_eq_hash() {
        use std::collections::HashSet;
        let id = Uuid::new_v4();
        let mut set = HashSet::new();
        set.insert(Identity::Local(id));
        assert!(set.contains(&Identity::Local(id)));
        assert!(!set.contains(&Identity::Remote(id)));
    }

    #[test]
    fn identity_clone_copy() {
        let id = Uuid::new_v4();
        let original = Identity::Remote(id);
        let copied = original;
        // Copy semantics: a fresh copy via plain assignment.
        let copied_again = original;
        assert_eq!(original, copied);
        assert_eq!(original, copied_again);
    }
}