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); } }
GitRust