feat(msg-bus): wire jobs follow up
This commit is contained in:
@@ -53,7 +53,7 @@ use crate::common::errors::DomainError;
|
||||
/// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ
|
||||
/// topic-exchange routing keys or NATS subjects when the [`BusReplicator`]
|
||||
/// seam is filled in later.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Topic {
|
||||
/// A folder's mutation stream — file/subfolder created/deleted/renamed/
|
||||
/// moved in or out. Consumed by the folder view for live refresh.
|
||||
@@ -63,6 +63,17 @@ pub enum Topic {
|
||||
/// subscribe the caller and evict stale subs when its events fire once
|
||||
/// the eviction wiring lands (Phase-A follow-up).
|
||||
UserAuthz(Uuid),
|
||||
|
||||
/// A named background job's run lifecycle — start / progress /
|
||||
/// end. Consumed by the admin job dashboard so operators who
|
||||
/// trigger a long-running job (backend migration, thumb import…)
|
||||
/// can navigate to other admin pages without losing progress
|
||||
/// visibility. AuthZ: **admin-only** (Class 3 role-scoped).
|
||||
/// Non-admins get `topic_forbidden` — indistinguishable on the
|
||||
/// wire from an unknown topic. Job names are stable
|
||||
/// scheduler-registered strings (e.g. `backend_migration`,
|
||||
/// `thumb_derived_import`); the topic string is `job:<name>`.
|
||||
Job(String),
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
@@ -72,6 +83,7 @@ impl Topic {
|
||||
match self {
|
||||
Topic::Folder(id) => format!("folder:{id}"),
|
||||
Topic::UserAuthz(id) => format!("user:{id}:authz"),
|
||||
Topic::Job(name) => format!("job:{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +102,22 @@ impl Topic {
|
||||
let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?;
|
||||
return Ok(Topic::UserAuthz(id));
|
||||
}
|
||||
if let Some(name) = s.strip_prefix("job:") {
|
||||
// Job names are scheduler-registered short slugs — see
|
||||
// `infrastructure/scheduler/registry.rs`. Validate here
|
||||
// only that the name is non-empty and consists of
|
||||
// `[a-z0-9_-]` chars — reject anything else as
|
||||
// `Unknown` (indistinguishable to the caller from a
|
||||
// topic shape we've never heard of).
|
||||
if !name.is_empty()
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
{
|
||||
return Ok(Topic::Job(name.to_string()));
|
||||
}
|
||||
return Err(ParseTopicErr::Unknown);
|
||||
}
|
||||
Err(ParseTopicErr::Unknown)
|
||||
}
|
||||
|
||||
@@ -107,6 +135,7 @@ impl Topic {
|
||||
resource: BusResource::Folder(*id),
|
||||
},
|
||||
Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id },
|
||||
Topic::Job(_) => AuthzCheck::RoleAdmin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,8 +181,12 @@ pub enum AuthzCheck {
|
||||
/// Class 2 — Identity-scoped. `caller_id` must equal `user_id`.
|
||||
/// No admin bypass — privacy is a hard rule.
|
||||
IdentityMatch { user_id: Uuid },
|
||||
// Class 3 (role-scoped `admin:*`) and the bespoke `job:{id}` check
|
||||
// land with their topic variants.
|
||||
|
||||
/// Class 3 — Role-scoped. Caller must hold the admin role. Used
|
||||
/// by `Topic::Job(_)` today; future `admin:*` topics land here.
|
||||
/// Non-admin subscriber gets `topic_forbidden` on the wire —
|
||||
/// same anti-enum shape as unknown-topic denial.
|
||||
RoleAdmin,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -257,6 +290,50 @@ pub enum MessageBusEvent {
|
||||
/// the payload extends with additional resource classes — see the
|
||||
/// plan's Phase-B roadmap.
|
||||
AuthzChanged { affected_folders: Vec<Uuid> },
|
||||
|
||||
/// A background job's run started. Published on
|
||||
/// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339
|
||||
/// serialised by serde). Admin dashboard's job-list view uses
|
||||
/// this to flip a row from "idle" to "running" without a
|
||||
/// polling round-trip.
|
||||
JobRunStarted {
|
||||
name: String,
|
||||
started_at: chrono::DateTime<chrono::Utc>,
|
||||
actor: Uuid,
|
||||
},
|
||||
|
||||
/// A background job made progress. Published at most every
|
||||
/// 3 seconds per job (throttled at the publish site — see
|
||||
/// scheduler engine). `step` / `total` populate an operator-
|
||||
/// facing progress bar; `message` is a one-line free-form
|
||||
/// status. All three are optional because different jobs have
|
||||
/// different progress semantics (some know the total up front,
|
||||
/// some don't; some can render a step count, some just have a
|
||||
/// running status message).
|
||||
JobRunProgress {
|
||||
name: String,
|
||||
step: Option<u64>,
|
||||
total: Option<u64>,
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// A background job's run ended. `success = true` for a normal
|
||||
/// completion; `false` for failure / cancelled / paused with
|
||||
/// unhandled outcome. `reason` populates the "click for
|
||||
/// details" flow on the admin dashboard: the notification (Slice
|
||||
/// E) will link to `/admin/jobs/<name>` on the `false` branch,
|
||||
/// where the full outcome and paused-run state live.
|
||||
///
|
||||
/// Deliberately NOT a rich outcome enum — the admin panel is one
|
||||
/// click away and holds the full detail; the bus event just
|
||||
/// needs to say "done, ok or not". Adding a new outcome nuance
|
||||
/// server-side does NOT churn the wire.
|
||||
JobRunEnded {
|
||||
name: String,
|
||||
success: bool,
|
||||
reason: Option<String>,
|
||||
ended_at: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -420,6 +497,33 @@ mod tests {
|
||||
assert_eq!(Topic::parse(&wire).unwrap(), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_topic_roundtrip() {
|
||||
let t = Topic::Job("backend_migration".to_string());
|
||||
let wire = t.to_wire_key();
|
||||
assert_eq!(wire, "job:backend_migration");
|
||||
assert_eq!(Topic::parse(&wire).unwrap(), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_topic_rejects_bad_name_chars() {
|
||||
// Job names come from the scheduler registry — a stable
|
||||
// `[a-z0-9_-]` alphabet. Anything else is `Unknown` (same
|
||||
// wire response as an unrecognised topic shape).
|
||||
assert_eq!(Topic::parse("job:"), Err(ParseTopicErr::Unknown));
|
||||
assert_eq!(Topic::parse("job:UPPER"), Err(ParseTopicErr::Unknown));
|
||||
assert_eq!(Topic::parse("job:with.dot"), Err(ParseTopicErr::Unknown));
|
||||
assert_eq!(Topic::parse("job:with space"), Err(ParseTopicErr::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_perm_job_is_role_admin() {
|
||||
assert_eq!(
|
||||
Topic::Job("thumb_derived_import".to_string()).required_perm(),
|
||||
AuthzCheck::RoleAdmin
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_uuid() {
|
||||
assert_eq!(
|
||||
@@ -547,6 +651,32 @@ mod tests {
|
||||
},
|
||||
"authz_changed",
|
||||
),
|
||||
(
|
||||
MessageBusEvent::JobRunStarted {
|
||||
name: "backend_migration".into(),
|
||||
started_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap(),
|
||||
actor: Uuid::nil(),
|
||||
},
|
||||
"job_run_started",
|
||||
),
|
||||
(
|
||||
MessageBusEvent::JobRunProgress {
|
||||
name: "backend_migration".into(),
|
||||
step: Some(10),
|
||||
total: Some(100),
|
||||
message: Some("phase 2".into()),
|
||||
},
|
||||
"job_run_progress",
|
||||
),
|
||||
(
|
||||
MessageBusEvent::JobRunEnded {
|
||||
name: "backend_migration".into(),
|
||||
success: true,
|
||||
reason: None,
|
||||
ended_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap(),
|
||||
},
|
||||
"job_run_ended",
|
||||
),
|
||||
];
|
||||
for (ev, expected) in cases {
|
||||
let json = serde_json::to_value(ev).unwrap();
|
||||
|
||||
@@ -140,6 +140,21 @@ fn channels() -> Value {
|
||||
"SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" },
|
||||
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
|
||||
}
|
||||
},
|
||||
"Job": {
|
||||
"address": "job:{jobName}",
|
||||
"description": "A named background job's run lifecycle — Started / Progress / Ended. Consumed by the admin dashboard so operators who trigger a long-running job (backend migration, thumb import…) can navigate off the admin page and come back without losing progress. AuthZ: admin-only (Class 3 role-scoped) — non-admin gets `topic_forbidden`, indistinguishable on the wire from an unknown topic.",
|
||||
"parameters": {
|
||||
"jobName": { "description": "Scheduler-registered short slug (e.g. `backend_migration`); `[a-z0-9_-]` chars only" }
|
||||
},
|
||||
"messages": {
|
||||
"SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" },
|
||||
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
|
||||
"SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" },
|
||||
"ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" },
|
||||
"JobEvent": { "$ref": "#/components/messages/RtFolderEventNotification" },
|
||||
"RevokedNotification": { "$ref": "#/components/messages/RtRevokedNotification" },
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -301,6 +316,9 @@ fn components() -> Value {
|
||||
"FolderRenamedData": folder_renamed_schema(),
|
||||
"FolderMovedData": folder_moved_schema(),
|
||||
"FolderDeletedData": folder_deleted_schema(),
|
||||
"JobRunStartedData": job_run_started_schema(),
|
||||
"JobRunProgressData": job_run_progress_schema(),
|
||||
"JobRunEndedData": job_run_ended_schema(),
|
||||
},
|
||||
// How the client authenticates. Handler side is `auth_middleware`
|
||||
// — the same middleware every `/api/*` request goes through, so
|
||||
@@ -580,6 +598,7 @@ fn event_kind_schema() -> Value {
|
||||
"enum": [
|
||||
"file_created", "file_renamed", "file_moved", "file_deleted",
|
||||
"folder_created", "folder_renamed", "folder_moved", "folder_deleted",
|
||||
"job_run_started", "job_run_progress", "job_run_ended",
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -596,6 +615,9 @@ fn event_data_union_schema() -> Value {
|
||||
ref_schema("FolderRenamedData"),
|
||||
ref_schema("FolderMovedData"),
|
||||
ref_schema("FolderDeletedData"),
|
||||
ref_schema("JobRunStartedData"),
|
||||
ref_schema("JobRunProgressData"),
|
||||
ref_schema("JobRunEndedData"),
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -710,6 +732,52 @@ fn folder_deleted_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────── Job event data payloads ─────────────────────
|
||||
// Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) —
|
||||
// non-admins get `topic_forbidden` on subscribe, so these payloads
|
||||
// only ever reach admin subscribers. See `handlers/rt_ws.rs`.
|
||||
|
||||
fn job_run_started_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "A background job's run started. `name` matches the scheduler-registered job name (e.g. `backend_migration`). `actor` is `00000000-0000-0000-0000-000000000000` today — the scheduler doesn't yet thread the trigger caller through.",
|
||||
"required": ["name", "started_at", "actor"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"started_at": { "type": "string", "format": "date-time" },
|
||||
"actor": { "type": "string", "format": "uuid" },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn job_run_progress_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "A background job made progress. Throttled at the publish site to at most one per 3 s per job (see scheduler engine). `step` / `total` populate a progress bar; all three fields are optional because different jobs report different granularities.",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"step": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"total": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"message": { "type": ["string", "null"] },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn job_run_ended_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "A background job's run ended. `success = true` for a normal completion; `false` for failure / timeout / cancelled / paused-with-unhandled-outcome. `reason` populates the toast text on the `false` branch and links to `/admin/jobs/<name>` for the full outcome. Consumer typically drops its subscription on receipt (job is done).",
|
||||
"required": ["name", "success", "ended_at"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"success": { "type": "boolean" },
|
||||
"reason": { "type": ["string", "null"] },
|
||||
"ended_at": { "type": "string", "format": "date-time" },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `rt.revoked` notification body — server tells the client that a
|
||||
/// specific subscription has been evicted. `topic` is the wire-form
|
||||
/// string the client originally subscribed to. `reason` is the stable
|
||||
|
||||
@@ -1759,6 +1759,17 @@ impl AppServiceFactory {
|
||||
Arc::new(crate::application::ports::message_bus_ports::NoopReplicator),
|
||||
);
|
||||
|
||||
// Wire the bus into the JobRegistry so `dispatch` (both the
|
||||
// periodic supervisor and the manual `trigger` paths) can
|
||||
// publish `JobRunStarted` / `JobRunEnded` on `Topic::Job(name)`.
|
||||
// Set here — after both the bus and the registry are
|
||||
// constructed — via `OnceLock`. Silent no-op on subsequent
|
||||
// calls; unit tests that build a registry without a bus just
|
||||
// skip this.
|
||||
let bus_for_jobs: Arc<dyn crate::application::ports::message_bus_ports::MessageBus> =
|
||||
bus.clone();
|
||||
core.job_registry.set_message_bus(bus_for_jobs);
|
||||
|
||||
// WebSocket ticket store — see `rt_ticket_store` module doc for
|
||||
// why this exists (DPoP-bound sessions can't be re-proofed on
|
||||
// a browser-issued WS upgrade). Reaper task runs for the app
|
||||
|
||||
@@ -94,8 +94,11 @@ async fn run(registry: Arc<JobRegistry>) {
|
||||
// Fire and forget from the supervisor's perspective — we
|
||||
// don't care about the outcome, `dispatch` records it on the
|
||||
// entry and emits the log line itself. Periodic ticks never
|
||||
// force — that's an admin-trigger-only affordance.
|
||||
let _ = dispatch(&name, entry, &JobRunArgs::default()).await;
|
||||
// force — that's an admin-trigger-only affordance. Pass the
|
||||
// bus reference so periodic runs also publish job events
|
||||
// (same reasoning as the manual-trigger path).
|
||||
let bus = registry.message_bus_snapshot();
|
||||
let _ = dispatch(&name, entry, &JobRunArgs::default(), bus).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +120,12 @@ async fn run(registry: Arc<JobRegistry>) {
|
||||
/// `args` is passed through to `JobHandler::run`. The supervisor's
|
||||
/// periodic ticks pass `JobRunArgs::default()`; the admin trigger
|
||||
/// endpoint forwards parsed query params such as `?force=true`.
|
||||
pub(super) async fn dispatch(name: &str, entry: Arc<JobEntry>, args: &JobRunArgs) -> JobOutcome {
|
||||
pub(super) async fn dispatch(
|
||||
name: &str,
|
||||
entry: Arc<JobEntry>,
|
||||
args: &JobRunArgs,
|
||||
bus: Option<std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
|
||||
) -> JobOutcome {
|
||||
// Try to acquire the single-permit gate. `try_acquire` is
|
||||
// non-blocking — if held, we know the previous run is still
|
||||
// executing and skip this tick.
|
||||
@@ -161,6 +169,26 @@ pub(super) async fn dispatch(name: &str, entry: Arc<JobEntry>, args: &JobRunArgs
|
||||
let started_wall = Utc::now();
|
||||
let start_instant = Instant::now();
|
||||
|
||||
// Publish `JobRunStarted` on `Topic::Job(name)` so the admin
|
||||
// job dashboard's live tab receives a "started" tick without
|
||||
// polling. Silent no-op when the bus isn't wired (test setup)
|
||||
// or when nobody is subscribed. `actor` is `Uuid::nil()` today
|
||||
// because the scheduler doesn't carry the trigger caller
|
||||
// through — the periodic supervisor has no caller, and the
|
||||
// admin trigger endpoints don't thread it in. When they do,
|
||||
// swap to the real UUID.
|
||||
if let Some(bus) = bus.as_ref() {
|
||||
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
|
||||
bus.publish(
|
||||
&Topic::Job(name.to_string()),
|
||||
MessageBusEvent::JobRunStarted {
|
||||
name: name.to_string(),
|
||||
started_at: started_wall,
|
||||
actor: uuid::Uuid::nil(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Spawn so panics land as `JoinError::is_panic()` instead of
|
||||
// unwinding into the supervisor loop. Args cloned into the spawn
|
||||
// scope so the borrow doesn't outlive the caller.
|
||||
@@ -214,6 +242,33 @@ pub(super) async fn dispatch(name: &str, entry: Arc<JobEntry>, args: &JobRunArgs
|
||||
// the diagnostic `cause` field.
|
||||
log_outcome(name, &outcome, cause, elapsed_ms);
|
||||
|
||||
// Publish `JobRunEnded` on `Topic::Job(name)`. This is the
|
||||
// signal the FE watches for to terminate its subscription
|
||||
// (`useJobTopic` unsubscribes on `onEnded`). `success = false`
|
||||
// covers timeout, panic, handler error — the admin dashboard
|
||||
// renders the row as failed and the "click for details"
|
||||
// notification (Slice E) will link to `/admin/jobs/<name>`.
|
||||
// Silent no-op when the bus isn't wired.
|
||||
if let Some(bus) = bus.as_ref() {
|
||||
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
|
||||
let success = outcome.is_ok();
|
||||
let reason = match &outcome {
|
||||
crate::infrastructure::scheduler::types::JobOutcome::Err { message } => {
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
bus.publish(
|
||||
&Topic::Job(name.to_string()),
|
||||
MessageBusEvent::JobRunEnded {
|
||||
name: name.to_string(),
|
||||
success,
|
||||
reason,
|
||||
ended_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
drop(permit);
|
||||
outcome
|
||||
}
|
||||
@@ -421,16 +476,15 @@ mod tests {
|
||||
// Kick off dispatch 1 in the background — it holds the permit
|
||||
// for ~200 ms.
|
||||
let entry_bg = entry.clone();
|
||||
let bg =
|
||||
tokio::spawn(
|
||||
async move { dispatch("overrun", entry_bg, &JobRunArgs::default()).await },
|
||||
);
|
||||
let bg = tokio::spawn(async move {
|
||||
dispatch("overrun", entry_bg, &JobRunArgs::default(), None).await
|
||||
});
|
||||
|
||||
// Give dispatch 1 time to grab the permit.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// Dispatch 2 should observe the permit taken and skip.
|
||||
dispatch("overrun", entry.clone(), &JobRunArgs::default()).await;
|
||||
dispatch("overrun", entry.clone(), &JobRunArgs::default(), None).await;
|
||||
|
||||
// Only dispatch 1's handler should have actually run so far.
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
@@ -458,7 +512,7 @@ mod tests {
|
||||
.await;
|
||||
let entry = registry.get("slow").await.unwrap();
|
||||
|
||||
dispatch("slow", entry.clone(), &JobRunArgs::default()).await;
|
||||
dispatch("slow", entry.clone(), &JobRunArgs::default(), None).await;
|
||||
|
||||
// The timeout fired; last_outcome must be Err.
|
||||
let state = entry.state.lock().unwrap();
|
||||
|
||||
@@ -60,12 +60,24 @@ pub(super) struct JobState {
|
||||
/// native services `register()` during DI wiring.
|
||||
pub struct JobRegistry {
|
||||
entries: RwLock<HashMap<String, Arc<JobEntry>>>,
|
||||
/// Message bus — used by `dispatch` (via `trigger`) to publish
|
||||
/// `JobRunStarted` / `JobRunProgress` / `JobRunEnded` on
|
||||
/// `Topic::Job(name)` so the admin dashboard can render live
|
||||
/// progress without polling. `OnceLock` because it's set exactly
|
||||
/// once at DI time (after both the registry and the bus are
|
||||
/// constructed) and read from many concurrent triggers; `Arc`
|
||||
/// keeps consumers cheap. `None` before wiring (unit tests
|
||||
/// exercise the registry without a bus).
|
||||
message_bus: std::sync::OnceLock<
|
||||
std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl JobRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
message_bus: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +306,33 @@ impl JobRegistry {
|
||||
/// that just want a plain run pass `JobRunArgs::default()`.
|
||||
pub async fn trigger(self: &Arc<Self>, name: &str, args: &JobRunArgs) -> Option<JobOutcome> {
|
||||
let entry = self.get(name).await?;
|
||||
Some(super::engine::dispatch(name, entry, args).await)
|
||||
// Pass the bus reference through to `dispatch` so start / end
|
||||
// events publish on `Topic::Job(name)`. `Option::cloned()`
|
||||
// returns a fresh `Arc` clone (or None) — negligible.
|
||||
let bus = self.message_bus.get().cloned();
|
||||
Some(super::engine::dispatch(name, entry, args, bus).await)
|
||||
}
|
||||
|
||||
/// Wire the message bus. Called once from DI after both the
|
||||
/// registry and the bus are constructed. Idempotent: a second
|
||||
/// call is a silent no-op (`OnceLock::set` returns `Err`), so
|
||||
/// test setups that call this more than once don't panic.
|
||||
pub fn set_message_bus(
|
||||
&self,
|
||||
bus: std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
|
||||
) {
|
||||
let _ = self.message_bus.set(bus);
|
||||
}
|
||||
|
||||
/// Snapshot the currently-wired bus (if any). `None` when
|
||||
/// `set_message_bus` hasn't been called yet — every test setup
|
||||
/// that skips DI wiring, and the very early boot before the
|
||||
/// bus is constructed. Called by the periodic supervisor and
|
||||
/// by `trigger` so both paths publish job events identically.
|
||||
pub(super) fn message_bus_snapshot(
|
||||
&self,
|
||||
) -> Option<std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>> {
|
||||
self.message_bus.get().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,13 @@ impl InProcessMessageBus {
|
||||
/// (for the receiver) — one code path for the map insert avoids a race
|
||||
/// where publish creates a sender concurrent subscribers miss.
|
||||
fn sender_for(&self, topic: &Topic) -> broadcast::Sender<MessageBusEvent> {
|
||||
// `topic.clone()` because `Topic::Job(String)` isn't `Copy`.
|
||||
// The clone is a String alloc on the cold path (first ever
|
||||
// subscriber for a topic) and free on the hot path (existing
|
||||
// entry — `entry` doesn't need to move the key when the
|
||||
// entry is already present).
|
||||
self.topics
|
||||
.entry(*topic)
|
||||
.entry(topic.clone())
|
||||
.or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0)
|
||||
.clone()
|
||||
}
|
||||
@@ -194,6 +199,9 @@ fn event_kind(event: &MessageBusEvent) -> &'static str {
|
||||
MessageBusEvent::FolderMoved { .. } => "folder_moved",
|
||||
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
||||
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
||||
MessageBusEvent::JobRunStarted { .. } => "job_run_started",
|
||||
MessageBusEvent::JobRunProgress { .. } => "job_run_progress",
|
||||
MessageBusEvent::JobRunEnded { .. } => "job_run_ended",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +338,48 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let _session_count_guard = SessionCountGuard(Arc::clone(&state.active_ws_sessions));
|
||||
|
||||
// Snapshot the caller's role ONCE per session, so the Class-3
|
||||
// (`RoleAdmin`) AuthZ dispatch inside `handle_subscribe` doesn't
|
||||
// pay a DB hop on every subscribe frame. `resolve_live_role`
|
||||
// honours the short-TTL flags cache, and a demotion mid-session
|
||||
// takes effect on the NEXT reconnect (bounded by
|
||||
// USER_FLAGS_CACHE_TTL for the flags read at that point). If
|
||||
// the auth service isn't wired (unusual test config) or the
|
||||
// account is revoked, treat as non-admin — fail-closed for
|
||||
// admin gates. Passing "user" as the claim role is fail-open
|
||||
// for `resolve_live_role`'s non-admin fallback path.
|
||||
let caller_role: String = match state.auth_service.as_ref() {
|
||||
Some(auth) => {
|
||||
match crate::interfaces::middleware::user::resolve_live_role(
|
||||
auth.auth_application_service.as_ref(),
|
||||
caller_id,
|
||||
"user",
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::interfaces::middleware::user::LiveRole::Active(role) => role.to_string(),
|
||||
crate::interfaces::middleware::user::LiveRole::Revoked => {
|
||||
// Account revoked between ticket-issue and now.
|
||||
// Terminate the session immediately — dropping
|
||||
// `socket` at end of scope closes the WS cleanly
|
||||
// (no explicit `.close()` needed; that would
|
||||
// require pulling `SinkExt` into scope for one
|
||||
// line).
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "message_bus.session_rejected",
|
||||
reason = "account_revoked",
|
||||
caller_id = %caller_id,
|
||||
"👮🏻♂️ WS session rejected — account revoked",
|
||||
);
|
||||
drop(socket);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => "user".to_string(),
|
||||
};
|
||||
|
||||
// Outbound queue — every path that produces a client-bound frame
|
||||
// enqueues here; the writer half of the select drains. Also
|
||||
// carries internal `EvictFolders` control signals from the
|
||||
@@ -450,7 +492,7 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(txt))) => {
|
||||
if let Some(reply) =
|
||||
handle_text_frame(&txt, caller_id, &state, &mut subs, &out_tx).await
|
||||
handle_text_frame(&txt, caller_id, &caller_role, &state, &mut subs, &out_tx).await
|
||||
&& socket.send(Message::Text(reply.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -487,6 +529,7 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
|
||||
async fn handle_text_frame(
|
||||
text: &str,
|
||||
caller_id: Uuid,
|
||||
caller_role: &str,
|
||||
state: &Arc<AppState>,
|
||||
subs: &mut HashMap<String, Sub>,
|
||||
out_tx: &mpsc::Sender<SessionOut>,
|
||||
@@ -516,9 +559,9 @@ async fn handle_text_frame(
|
||||
};
|
||||
|
||||
match method.as_str() {
|
||||
"rt.subscribe" => {
|
||||
Some(handle_subscribe(id, req.params, caller_id, state, subs, out_tx).await)
|
||||
}
|
||||
"rt.subscribe" => Some(
|
||||
handle_subscribe(id, req.params, caller_id, caller_role, state, subs, out_tx).await,
|
||||
),
|
||||
"rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)),
|
||||
"rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))),
|
||||
_ => Some(error_response(
|
||||
@@ -534,6 +577,7 @@ async fn handle_subscribe(
|
||||
id: Value,
|
||||
params: Value,
|
||||
caller_id: Uuid,
|
||||
caller_role: &str,
|
||||
state: &Arc<AppState>,
|
||||
subs: &mut HashMap<String, Sub>,
|
||||
out_tx: &mpsc::Sender<SessionOut>,
|
||||
@@ -622,6 +666,21 @@ async fn handle_subscribe(
|
||||
);
|
||||
}
|
||||
}
|
||||
AuthzCheck::RoleAdmin => {
|
||||
// Class 3 — role-scoped. Caller must be admin. `caller_role`
|
||||
// was snapshotted at session start (see `handle_session`),
|
||||
// so no per-subscribe DB hit. A demotion mid-session
|
||||
// takes effect on the caller's next reconnect.
|
||||
if caller_role != "admin" {
|
||||
audit_denied(caller_id, &topic_str, "role_denied");
|
||||
return error_response(
|
||||
id,
|
||||
error_code::TOPIC_FORBIDDEN,
|
||||
"topic_forbidden",
|
||||
Some(serde_json::json!({ "topic": topic_str })),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AuthZ passed — install the subscription and spawn a reader task
|
||||
|
||||
Reference in New Issue
Block a user