feat(msg-bus): add DPoP support, fix floow from client, correct deletion
This commit is contained in:
@@ -127,6 +127,27 @@ impl MessageBus for InProcessMessageBus {
|
||||
// replicators must background their I/O themselves.
|
||||
self.replicator.on_local_publish(topic, &event);
|
||||
|
||||
// Structured trace of every publish so operators can watch the
|
||||
// bus with `RUST_LOG=oxicloud::message_bus=debug`. Cheap:
|
||||
// shows the wire-form topic (uses the same Display we return
|
||||
// to WS clients), the event's discriminator (via serde), and
|
||||
// whether anyone was listening at publish time. Payload bodies
|
||||
// are NOT emitted here to keep the log line short and stable
|
||||
// across variant additions — `debug_span` or a per-service
|
||||
// publish site can log the payload if needed.
|
||||
let sub_count = self
|
||||
.topics
|
||||
.get(topic)
|
||||
.map(|s| s.receiver_count())
|
||||
.unwrap_or(0);
|
||||
tracing::debug!(
|
||||
target: "oxicloud::message_bus",
|
||||
topic = %topic.to_wire_key(),
|
||||
kind = event_kind(&event),
|
||||
subscribers = sub_count,
|
||||
"📤 bus publish",
|
||||
);
|
||||
|
||||
// If nobody is subscribed, don't allocate a sender just to drop
|
||||
// its message. `broadcast::Sender::send` returns Err when there
|
||||
// are no receivers — cheaper still to short-circuit here.
|
||||
@@ -155,6 +176,27 @@ impl MessageBus for InProcessMessageBus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snake-case discriminator string for the event, matching the wire
|
||||
/// `event` field. Lifted out of the `publish` hot path so the debug
|
||||
/// log stays a one-liner. Kept in sync with the `#[serde(tag =
|
||||
/// "event", rename_all = "snake_case")]` shape in
|
||||
/// `MessageBusEvent` — new variants get a new arm here to render
|
||||
/// nicely in the trace log; adding one that lands in the default is
|
||||
/// harmless (still readable), just less specific.
|
||||
fn event_kind(event: &MessageBusEvent) -> &'static str {
|
||||
match event {
|
||||
MessageBusEvent::FileCreated { .. } => "file_created",
|
||||
MessageBusEvent::FileRenamed { .. } => "file_renamed",
|
||||
MessageBusEvent::FileMoved { .. } => "file_moved",
|
||||
MessageBusEvent::FileDeleted { .. } => "file_deleted",
|
||||
MessageBusEvent::FolderCreated { .. } => "folder_created",
|
||||
MessageBusEvent::FolderRenamed { .. } => "folder_renamed",
|
||||
MessageBusEvent::FolderMoved { .. } => "folder_moved",
|
||||
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
||||
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -52,6 +52,7 @@ pub mod pg_acl_engine;
|
||||
pub mod plugins;
|
||||
pub mod recent_recording_hook;
|
||||
pub mod retry_blob_backend;
|
||||
pub mod rt_ticket_store;
|
||||
pub mod s3_blob_backend;
|
||||
pub mod satellites_consistency_service;
|
||||
pub mod search_index;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Short-lived tickets for authenticating a WebSocket upgrade.
|
||||
//!
|
||||
//! # Problem
|
||||
//!
|
||||
//! A DPoP-bound session must carry a fresh `DPoP:` header on every
|
||||
//! request. `new WebSocket(url)` in browsers cannot set arbitrary
|
||||
//! headers — only `Sec-WebSocket-Protocol` — so the upgrade GET
|
||||
//! arrives without a DPoP proof and `require_dpop_layer` refuses with
|
||||
//! 401 `proof_missing_on_bound_session`. See
|
||||
//! `docs/plan/message-bus.md § F`.
|
||||
//!
|
||||
//! # Solution
|
||||
//!
|
||||
//! Ticket exchange. The FE first `POST /api/rt/ticket` — a normal
|
||||
//! HTTP request, so `apiFetch` attaches the DPoP proof and every other
|
||||
//! middleware runs. The server mints an opaque one-shot ticket, tied
|
||||
//! to the caller_id and a 30 s expiry. The FE then opens the WS with
|
||||
//! `Sec-WebSocket-Protocol: oxi.ticket.<uuid>`; the WS handler
|
||||
//! redeems the ticket via this store to recover the caller_id, then
|
||||
//! runs the session with zero auth-middleware involvement.
|
||||
//!
|
||||
//! # Invariants
|
||||
//!
|
||||
//! - **Single-use** — `redeem` removes the entry atomically, so a
|
||||
//! captured ticket can be replayed at most once (the race is decided
|
||||
//! by the first successful `remove`; every other caller gets `None`).
|
||||
//! - **Short-lived** — 30 s TTL. A captured ticket that isn't burned
|
||||
//! inside that window is inert.
|
||||
//! - **Opaque** — the token carries no user identity itself. All the
|
||||
//! auth data lives in the store keyed by the token. Losing the store
|
||||
//! invalidates every issued ticket; that's the correct failure mode.
|
||||
//! - **In-process** — one store per process. Multi-instance
|
||||
//! deployments will need a shared backend (Redis, PG); calling it
|
||||
//! out here so the seam is visible when the day comes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// TTL for a freshly-minted ticket. 30 s covers the round-trip from
|
||||
/// `/api/rt/ticket` response to `new WebSocket()` handshake on any
|
||||
/// realistic network — well under the shortest sensible clock skew
|
||||
/// budget, well above the 100–500 ms actually needed on localhost or
|
||||
/// LAN. Kept as a compile-time constant; if operators ever want to
|
||||
/// tune it, promote to config.
|
||||
pub const TICKET_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Reaper cadence. Every N seconds the store walks its entries and
|
||||
/// drops expired ones. Redemption also lazily short-circuits on
|
||||
/// expiry, so the reaper is a memory-hygiene backstop rather than a
|
||||
/// correctness gate — a ticket that expires and is never redeemed
|
||||
/// stays around for up to `TICKET_TTL + REAPER_INTERVAL` before its
|
||||
/// row is freed.
|
||||
pub const REAPER_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Wire prefix identifying our tickets in `Sec-WebSocket-Protocol`.
|
||||
/// The full value on the wire is `oxi.ticket.<uuid>` — one
|
||||
/// subprotocol string, opaque to intermediaries. Kept short so
|
||||
/// stripping proxies don't hit an arbitrary length limit.
|
||||
pub const SUBPROTOCOL_PREFIX: &str = "oxi.ticket.";
|
||||
|
||||
struct Entry {
|
||||
caller_id: Uuid,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// In-process ticket store. Cheap to construct; the reaper task is
|
||||
/// spawned by DI when the store is wired.
|
||||
pub struct RtTicketStore {
|
||||
entries: DashMap<Uuid, Entry>,
|
||||
}
|
||||
|
||||
impl RtTicketStore {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
entries: DashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a fresh ticket for `caller_id`. Returns the opaque token
|
||||
/// (a UUIDv4 string) — the FE puts this on the wire as
|
||||
/// `Sec-WebSocket-Protocol: oxi.ticket.<uuid>`.
|
||||
///
|
||||
/// Ticket IDs are v4 (random) — 122 bits of entropy, well above
|
||||
/// the "unguessable-token" bar even without server-side rate
|
||||
/// limiting. A serial or timestamped id would leak issue-order
|
||||
/// signal to anyone with a wire tap.
|
||||
pub fn issue(&self, caller_id: Uuid) -> Uuid {
|
||||
let ticket = Uuid::new_v4();
|
||||
self.entries.insert(
|
||||
ticket,
|
||||
Entry {
|
||||
caller_id,
|
||||
expires_at: Instant::now() + TICKET_TTL,
|
||||
},
|
||||
);
|
||||
ticket
|
||||
}
|
||||
|
||||
/// Redeem `ticket` if it exists AND has not expired. Removes the
|
||||
/// entry regardless of outcome — a valid ticket returns the
|
||||
/// caller_id, an expired ticket is silently freed and returns
|
||||
/// `None`. Single-use invariant holds by construction: only one
|
||||
/// caller wins the `remove`, everyone else sees `None`.
|
||||
pub fn redeem(&self, ticket: Uuid) -> Option<Uuid> {
|
||||
let (_, entry) = self.entries.remove(&ticket)?;
|
||||
if entry.expires_at < Instant::now() {
|
||||
return None;
|
||||
}
|
||||
Some(entry.caller_id)
|
||||
}
|
||||
|
||||
/// Background reaper. Walks the map on the configured cadence and
|
||||
/// removes expired entries. Runs until the returned handle is
|
||||
/// dropped or `cancel` is notified (per the standard shutdown
|
||||
/// contract used across the crate).
|
||||
pub fn spawn_reaper(self: Arc<Self>) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(REAPER_INTERVAL);
|
||||
// First tick fires immediately; skip it so the store has
|
||||
// at least one TTL window's worth of entries before the
|
||||
// first sweep.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let now = Instant::now();
|
||||
self.entries.retain(|_, entry| entry.expires_at >= now);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Present count. Test-only. Not exposed to handlers — no
|
||||
/// operational reason to peek at the queue depth from a request
|
||||
/// path.
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn issue_then_redeem_returns_caller_id() {
|
||||
let store = RtTicketStore::new();
|
||||
let caller = Uuid::new_v4();
|
||||
let ticket = store.issue(caller);
|
||||
assert_eq!(store.redeem(ticket), Some(caller));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redeem_is_single_use() {
|
||||
let store = RtTicketStore::new();
|
||||
let caller = Uuid::new_v4();
|
||||
let ticket = store.issue(caller);
|
||||
assert_eq!(store.redeem(ticket), Some(caller));
|
||||
// Second redeem finds nothing — replay protection.
|
||||
assert_eq!(store.redeem(ticket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redeem_unknown_returns_none() {
|
||||
let store = RtTicketStore::new();
|
||||
assert_eq!(store.redeem(Uuid::new_v4()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issued_ticket_is_present_in_store() {
|
||||
let store = RtTicketStore::new();
|
||||
let caller = Uuid::new_v4();
|
||||
assert_eq!(store.len(), 0);
|
||||
let _ticket = store.issue(caller);
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redeem_after_expiry_returns_none_and_frees_entry() {
|
||||
// Use a synthetic entry with `expires_at` in the past so the
|
||||
// test doesn't have to sleep 30 s.
|
||||
let store = RtTicketStore::new();
|
||||
let caller = Uuid::new_v4();
|
||||
let ticket = Uuid::new_v4();
|
||||
store.entries.insert(
|
||||
ticket,
|
||||
Entry {
|
||||
caller_id: caller,
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
assert_eq!(store.len(), 1);
|
||||
// Expired redeem returns None…
|
||||
assert_eq!(store.redeem(ticket), None);
|
||||
// …and the entry is gone.
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_tickets_for_the_same_caller() {
|
||||
// Two issues in a row must produce distinct token ids — the
|
||||
// FE will issue one per WS reconnect, and a collision would
|
||||
// mean the second issue clobbers the first's expiry map row.
|
||||
let store = RtTicketStore::new();
|
||||
let caller = Uuid::new_v4();
|
||||
let t1 = store.issue(caller);
|
||||
let t2 = store.issue(caller);
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
|
||||
// Wall-clock testing of the reaper's timer needs the tokio
|
||||
// `test-util` feature; not enabled crate-wide. The reaper body is
|
||||
// a straight `entries.retain(|_, e| e.expires_at >= now)` and the
|
||||
// redemption path already lazily short-circuits on expiry (see
|
||||
// `redeem_after_expiry_returns_none_and_frees_entry`), which
|
||||
// exercises the same expiry decision without waiting on a real
|
||||
// clock.
|
||||
|
||||
#[test]
|
||||
fn is_send_sync_arc_shareable() {
|
||||
// Mirrors the actual usage in `AppState` — an
|
||||
// `Arc<RtTicketStore>` shared across the axum-served tasks.
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Arc<RtTicketStore>>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user