feat(msg-bus): wire jobs follow up

This commit is contained in:
Edouard Vanbelle
2026-09-11 19:41:18 +02:00
parent 84ea005b65
commit a6138aa4d9
16 changed files with 766 additions and 82 deletions
+133 -3
View File
@@ -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();
+68
View File
@@ -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
+11
View File
@@ -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
+63 -9
View File
@@ -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();
+39 -1
View File
@@ -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",
}
}
+63 -4
View File
@@ -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