feat(message-bus): add subscribtion eviction on grant revocation
change also plan to implement frontend types generation from AsyncAPI
This commit is contained in:
@@ -603,6 +603,46 @@ Land with their producer PRs; each is a small addition to
|
|||||||
polymorphic `RtFolderEventNotification` with `oneOf`. Better
|
polymorphic `RtFolderEventNotification` with `oneOf`. Better
|
||||||
codegen for typed clients. Refactor when we generate an FE SDK.
|
codegen for typed clients. Refactor when we generate an FE SDK.
|
||||||
|
|
||||||
|
### TypeScript client codegen via `@asyncapi/modelina` (Phase-A polish)
|
||||||
|
|
||||||
|
AsyncAPI has the same "spec → typed FE SDK" story OpenAPI has. Wire
|
||||||
|
it once, avoid hand-maintaining a growing catalog of message types.
|
||||||
|
|
||||||
|
- **Tool:** `@asyncapi/modelina` — the AsyncAPI-native model
|
||||||
|
generator. Reads `resources/gen/asyncapi.json`, emits TypeScript
|
||||||
|
interfaces + tagged unions for every message and schema. Actively
|
||||||
|
maintained, produces idiomatic TS.
|
||||||
|
- **Not** `@asyncapi/generator`'s WebSocket TEMPLATE — that generates
|
||||||
|
a full client SDK on assumptions (fetch shape, subscription model)
|
||||||
|
that don't match our `useTopic` singleton store. Custom composable
|
||||||
|
stays; only the message DTOs come from codegen.
|
||||||
|
- **Wiring:**
|
||||||
|
- `frontend/package.json` dev-dep: `@asyncapi/modelina`.
|
||||||
|
- Script `frontend/scripts/gen-realtime-types.mjs` invokes Modelina,
|
||||||
|
writes to `frontend/src/lib/generated/realtime/`.
|
||||||
|
- `just asyncapi-ts` recipe alongside `just asyncapi`.
|
||||||
|
- CI dirty-tree check — regenerate on every build, fail if `git
|
||||||
|
diff` on the generated folder is non-empty. Same discipline as
|
||||||
|
OpenAPI's check.
|
||||||
|
- Generated files carry a `// AUTO-GENERATED — do not edit; run
|
||||||
|
`just asyncapi-ts` to regenerate` header.
|
||||||
|
- **What the FE gets:**
|
||||||
|
- `type RtEvent = FileCreatedData | FileRenamedData | ...` — a
|
||||||
|
tagged union keyed on the `event` discriminator, so the folder
|
||||||
|
view's `switch (evt.event)` is exhaustive at compile time.
|
||||||
|
- `RtSubscribeRequestBody`, `RtErrorResponseBody`, error-code enum,
|
||||||
|
`RtPongResponseBody.result.pong === true` narrowed by type.
|
||||||
|
- No divergence between wire spec and FE types — the CI check
|
||||||
|
catches drift.
|
||||||
|
- **Also worth:** if we ever want a typed WS client for other
|
||||||
|
languages (Rust sync client, Python integration), the AsyncAPI
|
||||||
|
spec is the source; Modelina supports 8+ target languages.
|
||||||
|
- **Timing:** the current spec covers 8 event variants + 6 message
|
||||||
|
envelopes. Marginal savings today; substantial as Phase B adds
|
||||||
|
~15 more event variants (comments, mentions, presence, share
|
||||||
|
events). Set up now so the discipline is in place BEFORE the
|
||||||
|
surface grows.
|
||||||
|
|
||||||
## AuthZ model (audit rules per AGENTS.md)
|
## AuthZ model (audit rules per AGENTS.md)
|
||||||
|
|
||||||
### The subscribe gate
|
### The subscribe gate
|
||||||
|
|||||||
@@ -245,6 +245,18 @@ pub enum RealtimeEvent {
|
|||||||
parent_id: Uuid,
|
parent_id: Uuid,
|
||||||
actor: Uuid,
|
actor: Uuid,
|
||||||
},
|
},
|
||||||
|
/// A user's authorization changed — publishes on
|
||||||
|
/// [`Topic::UserAuthz`]. The WS handler auto-subscribes each
|
||||||
|
/// session to its own `user:{caller}:authz` topic; on receipt it
|
||||||
|
/// walks the session's active subscriptions and evicts any whose
|
||||||
|
/// resource is in `affected_folders`, emitting a `rt.revoked`
|
||||||
|
/// notification per evicted topic.
|
||||||
|
///
|
||||||
|
/// MVP carries folder UUIDs only (the only resource-scoped topic
|
||||||
|
/// that ships in Phase A). When file/drive/calendar topics land,
|
||||||
|
/// the payload extends with additional resource classes — see the
|
||||||
|
/// plan's Phase-B roadmap.
|
||||||
|
AuthzChanged { affected_folders: Vec<Uuid> },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -529,6 +541,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"folder_deleted",
|
"folder_deleted",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
RealtimeEvent::AuthzChanged {
|
||||||
|
affected_folders: vec![Uuid::nil()],
|
||||||
|
},
|
||||||
|
"authz_changed",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
for (ev, expected) in cases {
|
for (ev, expected) in cases {
|
||||||
let json = serde_json::to_value(ev).unwrap();
|
let json = serde_json::to_value(ev).unwrap();
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ fn channels() -> Value {
|
|||||||
"SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" },
|
"SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" },
|
||||||
"ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" },
|
"ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" },
|
||||||
"FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" },
|
"FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" },
|
||||||
|
"RevokedNotification": { "$ref": "#/components/messages/RtRevokedNotification" },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"UserAuthz": {
|
"UserAuthz": {
|
||||||
@@ -165,6 +166,14 @@ fn operations() -> Value {
|
|||||||
{ "$ref": "#/channels/Folder/messages/FolderEvent" }
|
{ "$ref": "#/channels/Folder/messages/FolderEvent" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"receiveRevoked": {
|
||||||
|
"action": "receive",
|
||||||
|
"channel": { "$ref": "#/channels/Folder" },
|
||||||
|
"summary": "Server-initiated eviction of a subscription (grant revoked, resource deleted, etc.). Client stops rendering the topic.",
|
||||||
|
"messages": [
|
||||||
|
{ "$ref": "#/channels/Folder/messages/RevokedNotification" }
|
||||||
|
]
|
||||||
|
},
|
||||||
// Application-layer keepalive. Separate from the RFC 6455 Ping
|
// Application-layer keepalive. Separate from the RFC 6455 Ping
|
||||||
// control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS`
|
// control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS`
|
||||||
// (which is transport-level and not modelled in AsyncAPI). This
|
// (which is transport-level and not modelled in AsyncAPI). This
|
||||||
@@ -235,6 +244,12 @@ fn components() -> Value {
|
|||||||
"title": "Folder mutation event",
|
"title": "Folder mutation event",
|
||||||
"contentType": "application/json",
|
"contentType": "application/json",
|
||||||
"payload": { "$ref": "#/components/schemas/RtFolderEventBody" },
|
"payload": { "$ref": "#/components/schemas/RtFolderEventBody" },
|
||||||
|
},
|
||||||
|
"RtRevokedNotification": {
|
||||||
|
"name": "rt.revoked",
|
||||||
|
"title": "Subscription evicted",
|
||||||
|
"contentType": "application/json",
|
||||||
|
"payload": { "$ref": "#/components/schemas/RtRevokedBody" },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
@@ -245,6 +260,7 @@ fn components() -> Value {
|
|||||||
"RtPongResponseBody": rpc_pong_response_schema(),
|
"RtPongResponseBody": rpc_pong_response_schema(),
|
||||||
"RtErrorResponseBody": rpc_error_response_schema(),
|
"RtErrorResponseBody": rpc_error_response_schema(),
|
||||||
"RtFolderEventBody": folder_event_notification_schema(),
|
"RtFolderEventBody": folder_event_notification_schema(),
|
||||||
|
"RtRevokedBody": revoked_notification_schema(),
|
||||||
"FileCreatedData": file_created_schema(),
|
"FileCreatedData": file_created_schema(),
|
||||||
"FileRenamedData": file_renamed_schema(),
|
"FileRenamedData": file_renamed_schema(),
|
||||||
"FileMovedData": file_moved_schema(),
|
"FileMovedData": file_moved_schema(),
|
||||||
@@ -526,3 +542,36 @@ fn folder_deleted_schema() -> Value {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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
|
||||||
|
/// eviction vocabulary — never repurpose an existing value (matches
|
||||||
|
/// the AuthZ audit-line convention).
|
||||||
|
fn revoked_notification_schema() -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"description": "JSON-RPC notification (no `id`). `method = \"rt.revoked\"`.",
|
||||||
|
"required": ["jsonrpc", "method", "params"],
|
||||||
|
"properties": {
|
||||||
|
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||||
|
"method": { "type": "string", "const": "rt.revoked" },
|
||||||
|
"params": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["topic", "reason"],
|
||||||
|
"properties": {
|
||||||
|
"topic": { "type": "string" },
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"grant_revoked",
|
||||||
|
"resource_deleted",
|
||||||
|
"group_membership_lost",
|
||||||
|
"admin_kick",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,6 +261,11 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut events: Vec<Value> = Vec::new();
|
let mut events: Vec<Value> = Vec::new();
|
||||||
|
// Server-initiated eviction notifications (`rt.revoked`) — captured
|
||||||
|
// separately from `rt.event` so scenarios can assert on eviction
|
||||||
|
// scoping (evicted topic vs. surviving topic) without conflating
|
||||||
|
// them with real content events.
|
||||||
|
let mut revoked: Vec<Value> = Vec::new();
|
||||||
// Count server-initiated protocol Pings so scenarios can assert the
|
// Count server-initiated protocol Pings so scenarios can assert the
|
||||||
// keepalive fires. tokio-tungstenite queues an auto-Pong on the next
|
// keepalive fires. tokio-tungstenite queues an auto-Pong on the next
|
||||||
// write path, so we don't need to send one ourselves; we just observe
|
// write path, so we don't need to send one ourselves; we just observe
|
||||||
@@ -327,14 +332,25 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> {
|
|||||||
|
|
||||||
// Notification (id-less)?
|
// Notification (id-less)?
|
||||||
let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
|
let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if method == "rt.event"
|
match method {
|
||||||
&& let Some(params) = value.get("params")
|
"rt.event" => {
|
||||||
{
|
if let Some(params) = value.get("params") {
|
||||||
events.push(params.clone());
|
events.push(params.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"rt.revoked" => {
|
||||||
|
// Server evicted one of our subscriptions. Record for
|
||||||
|
// the shell to assert on; do NOT increment `events` —
|
||||||
|
// eviction is orthogonal to content delivery.
|
||||||
|
if let Some(params) = value.get("params") {
|
||||||
|
revoked.push(params.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown notification method — ignored. `rt.pong` and
|
||||||
|
// future server-pushed methods land here silently.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Other notifications (`rt.revoked`, `rt.pong`) — ignored for
|
|
||||||
// subscribe-and-collect. They can be added to the output
|
|
||||||
// schema when scenarios need them.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assertion: at least `expect_events` collected before timeout.
|
// Assertion: at least `expect_events` collected before timeout.
|
||||||
@@ -345,6 +361,7 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> {
|
|||||||
let summary = json!({
|
let summary = json!({
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"events": events,
|
"events": events,
|
||||||
|
"revoked": revoked,
|
||||||
"pings_received": pings_received,
|
"pings_received": pings_received,
|
||||||
"timed_out": timed_out,
|
"timed_out": timed_out,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -504,6 +504,26 @@ pub async fn revoke_grant(
|
|||||||
self_revoke = (granter == caller_id),
|
self_revoke = (granter == caller_id),
|
||||||
"🗑️ grant revoked",
|
"🗑️ grant revoked",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Realtime eviction cascade — the revoke committed, so any WS
|
||||||
|
// session that had the affected user auto-subscribed to
|
||||||
|
// `user:{u}:authz` gets an AuthzChanged event and drops any live
|
||||||
|
// subscriptions to the affected resource. Silent no-op when the
|
||||||
|
// subject isn't a User (Group / Token subjects don't have live
|
||||||
|
// sessions to notify — group cascade is Phase-B once group
|
||||||
|
// membership expansion ships). Folder resources only for MVP;
|
||||||
|
// File/Drive topics don't exist yet.
|
||||||
|
if let (Subject::User(target_user), Resource::Folder(folder_id)) = (subject, resource) {
|
||||||
|
use crate::application::ports::realtime_ports::{RealtimeBus, RealtimeEvent, Topic};
|
||||||
|
RealtimeBus::publish(
|
||||||
|
state.bus.as_ref(),
|
||||||
|
&Topic::UserAuthz(target_user),
|
||||||
|
RealtimeEvent::AuthzChanged {
|
||||||
|
affected_folders: vec![folder_id],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,16 +184,45 @@ impl Drop for Sub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Messages the per-topic reader tasks send to the session's main
|
||||||
|
/// loop. Two shapes:
|
||||||
|
///
|
||||||
|
/// - `Frame` — a client-bound text frame (`rt.event` notification,
|
||||||
|
/// `rt.revoked` notification, whatever). Main loop writes it to
|
||||||
|
/// the socket.
|
||||||
|
/// - `EvictFolders` — internal control signal. The reader for the
|
||||||
|
/// session's auto-subscribed `user:{caller}:authz` topic translates
|
||||||
|
/// inbound [`RealtimeEvent::AuthzChanged`] events into this rather
|
||||||
|
/// than a client-visible frame. Main loop walks its subs, drops any
|
||||||
|
/// whose resource is in the list, and emits one `rt.revoked` frame
|
||||||
|
/// per evicted topic.
|
||||||
|
enum SessionOut {
|
||||||
|
Frame(String),
|
||||||
|
EvictFolders(Vec<Uuid>),
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppState>) {
|
async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppState>) {
|
||||||
// Outbound queue — every path that produces a text frame for the
|
// Outbound queue — every path that produces a client-bound frame
|
||||||
// client enqueues here; the writer half of the select drains.
|
// enqueues here; the writer half of the select drains. Also
|
||||||
let (out_tx, mut out_rx) = mpsc::channel::<String>(OUTBOUND_CHANNEL_CAPACITY);
|
// carries internal `EvictFolders` control signals from the
|
||||||
|
// authz reader — the main loop reacts to those without them
|
||||||
|
// hitting the socket.
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel::<SessionOut>(OUTBOUND_CHANNEL_CAPACITY);
|
||||||
|
|
||||||
// Active subscriptions on this session. Keyed by the wire-form topic
|
// Active subscriptions on this session. Keyed by the wire-form topic
|
||||||
// string so an incoming `rt.unsubscribe` with the same string is
|
// string so an incoming `rt.unsubscribe` with the same string is
|
||||||
// recognised without re-parsing.
|
// recognised without re-parsing.
|
||||||
let mut subs: HashMap<String, Sub> = HashMap::new();
|
let mut subs: HashMap<String, Sub> = HashMap::new();
|
||||||
|
|
||||||
|
// Auto-subscribe to the caller's private authz-change topic.
|
||||||
|
// No AuthZ check (identity-scoped: caller_id == user_id by
|
||||||
|
// construction), no client `rt.subscribe` frame. The reader for
|
||||||
|
// this topic translates `AuthzChanged` events into
|
||||||
|
// `SessionOut::EvictFolders` signals instead of pushing an
|
||||||
|
// `rt.event` notification the client can see — client-visible
|
||||||
|
// effect is the `rt.revoked` per evicted sub.
|
||||||
|
install_subscription(Topic::UserAuthz(caller_id), &mut subs, &out_tx, &state);
|
||||||
|
|
||||||
// Server-initiated protocol Ping ticker — prevents intermediate
|
// Server-initiated protocol Ping ticker — prevents intermediate
|
||||||
// proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping
|
// proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping
|
||||||
// the TCP session as idle. Browsers can't send Ping control frames
|
// the TCP session as idle. Browsers can't send Ping control frames
|
||||||
@@ -238,11 +267,36 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
|
|||||||
|
|
||||||
outbound = out_rx.recv() => {
|
outbound = out_rx.recv() => {
|
||||||
match outbound {
|
match outbound {
|
||||||
Some(text) => {
|
Some(SessionOut::Frame(text)) => {
|
||||||
if socket.send(Message::Text(text.into())).await.is_err() {
|
if socket.send(Message::Text(text.into())).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(SessionOut::EvictFolders(folders)) => {
|
||||||
|
// Grant-revocation cascade. Walk the sub set;
|
||||||
|
// drop any Folder(id) whose id is in the list;
|
||||||
|
// emit one `rt.revoked` frame per eviction so
|
||||||
|
// the client knows to stop rendering that
|
||||||
|
// resource. Idempotent: re-evicting an
|
||||||
|
// already-gone topic is a no-op.
|
||||||
|
for folder_uuid in folders {
|
||||||
|
let wire = Topic::Folder(folder_uuid).to_wire_key();
|
||||||
|
if subs.remove(&wire).is_some() {
|
||||||
|
let frame = revoked_notification(
|
||||||
|
&wire,
|
||||||
|
"grant_revoked",
|
||||||
|
);
|
||||||
|
if socket
|
||||||
|
.send(Message::Text(frame.into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return; // session dead
|
||||||
|
}
|
||||||
|
audit_evicted(caller_id, &wire, "grant_revoked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
None => break, // out_tx dropped — unreachable but safe
|
None => break, // out_tx dropped — unreachable but safe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +353,7 @@ async fn handle_text_frame(
|
|||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
subs: &mut HashMap<String, Sub>,
|
subs: &mut HashMap<String, Sub>,
|
||||||
out_tx: &mpsc::Sender<String>,
|
out_tx: &mpsc::Sender<SessionOut>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
// Parse envelope. On malformed JSON: reply with an id-less error per
|
// Parse envelope. On malformed JSON: reply with an id-less error per
|
||||||
// JSON-RPC 2.0 (id = null when the request couldn't be parsed).
|
// JSON-RPC 2.0 (id = null when the request couldn't be parsed).
|
||||||
@@ -346,7 +400,7 @@ async fn handle_subscribe(
|
|||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
subs: &mut HashMap<String, Sub>,
|
subs: &mut HashMap<String, Sub>,
|
||||||
out_tx: &mpsc::Sender<String>,
|
out_tx: &mpsc::Sender<SessionOut>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Extract topic.
|
// Extract topic.
|
||||||
let topic_str = match params.get("topic").and_then(Value::as_str) {
|
let topic_str = match params.get("topic").and_then(Value::as_str) {
|
||||||
@@ -437,20 +491,7 @@ async fn handle_subscribe(
|
|||||||
// AuthZ passed — install the subscription and spawn a reader task
|
// AuthZ passed — install the subscription and spawn a reader task
|
||||||
// that forwards bus events to the outbound channel as `rt.event`
|
// that forwards bus events to the outbound channel as `rt.event`
|
||||||
// notifications.
|
// notifications.
|
||||||
let stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic);
|
install_subscription(topic, subs, out_tx, state);
|
||||||
let topic_wire = topic_str.clone();
|
|
||||||
let out_tx_task = out_tx.clone();
|
|
||||||
let reader = tokio::spawn(async move {
|
|
||||||
let mut stream = stream;
|
|
||||||
while let Some(event) = stream.next().await {
|
|
||||||
let notification = event_notification(&topic_wire, &event);
|
|
||||||
if out_tx_task.send(notification).await.is_err() {
|
|
||||||
// Session's outbound channel closed — receiver dropped.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
subs.insert(topic_str.clone(), Sub { reader });
|
|
||||||
|
|
||||||
success_response(id, serde_json::json!({ "subscribed": topic_str }))
|
success_response(id, serde_json::json!({ "subscribed": topic_str }))
|
||||||
}
|
}
|
||||||
@@ -470,6 +511,62 @@ fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap<String, Sub>)
|
|||||||
success_response(id, serde_json::json!({ "unsubscribed": topic_str }))
|
success_response(id, serde_json::json!({ "unsubscribed": topic_str }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Subscription installer
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Spawn a reader task for `topic` and insert it into `subs`. No AuthZ
|
||||||
|
/// check — the caller is responsible for gating (either via
|
||||||
|
/// `handle_subscribe`'s explicit dispatch, or via identity-by-
|
||||||
|
/// construction for the auto-subscribed `Topic::UserAuthz(caller)`).
|
||||||
|
///
|
||||||
|
/// The reader interprets bus events differently by topic class:
|
||||||
|
///
|
||||||
|
/// - For `Topic::UserAuthz(_)`: an incoming `RealtimeEvent::AuthzChanged`
|
||||||
|
/// is translated to `SessionOut::EvictFolders(affected)` — the main
|
||||||
|
/// loop then walks the sub set and drops matching topics. Any other
|
||||||
|
/// event kind on this topic is ignored (defensive; shouldn't happen
|
||||||
|
/// in MVP).
|
||||||
|
/// - For every other topic: bus events are wrapped into a client-
|
||||||
|
/// visible `rt.event` notification and pushed as `SessionOut::Frame`.
|
||||||
|
fn install_subscription(
|
||||||
|
topic: Topic,
|
||||||
|
subs: &mut HashMap<String, Sub>,
|
||||||
|
out_tx: &mpsc::Sender<SessionOut>,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
) {
|
||||||
|
let topic_wire = topic.to_wire_key();
|
||||||
|
let mut stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic);
|
||||||
|
let out_tx_task = out_tx.clone();
|
||||||
|
let translate_authz = matches!(topic, Topic::UserAuthz(_));
|
||||||
|
// Clone for the reader closure; keep the original to key `subs`.
|
||||||
|
let topic_wire_reader = topic_wire.clone();
|
||||||
|
|
||||||
|
let reader = tokio::spawn(async move {
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
|
let message = if translate_authz {
|
||||||
|
match event {
|
||||||
|
RealtimeEvent::AuthzChanged { affected_folders } => {
|
||||||
|
SessionOut::EvictFolders(affected_folders)
|
||||||
|
}
|
||||||
|
// The authz topic only carries AuthzChanged in
|
||||||
|
// MVP; other variants would be a producer bug —
|
||||||
|
// drop them silently so a mis-wired publish
|
||||||
|
// doesn't spam the client.
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SessionOut::Frame(event_notification(&topic_wire_reader, &event))
|
||||||
|
};
|
||||||
|
if out_tx_task.send(message).await.is_err() {
|
||||||
|
// Session's outbound channel closed — receiver dropped.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
subs.insert(topic_wire, Sub { reader });
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// Envelope helpers
|
// Envelope helpers
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -538,6 +635,22 @@ fn split_event_discriminator(mut event_json: Value) -> (String, Value) {
|
|||||||
("unknown".to_owned(), event_json)
|
("unknown".to_owned(), event_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the server-initiated `rt.revoked` JSON-RPC notification.
|
||||||
|
/// Emitted when a subscription is evicted mid-session (grant revoked,
|
||||||
|
/// resource deleted, etc.). Not tied to a request id — client sees
|
||||||
|
/// this as a signal to stop rendering the topic.
|
||||||
|
fn revoked_notification(topic_wire: &str, reason: &'static str) -> String {
|
||||||
|
serde_json::to_string(&RpcNotification {
|
||||||
|
jsonrpc: JSONRPC_V2,
|
||||||
|
method: "rt.revoked",
|
||||||
|
params: serde_json::json!({
|
||||||
|
"topic": topic_wire,
|
||||||
|
"reason": reason,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.expect("RpcNotification always serializes")
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// Audit
|
// Audit
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -553,6 +666,20 @@ fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audit line for server-initiated eviction — every `rt.revoked`
|
||||||
|
/// frame we send should also have a durable trail. Stable `reason`
|
||||||
|
/// vocabulary matches the WS wire's `reason` field.
|
||||||
|
fn audit_evicted(caller_id: Uuid, topic: &str, reason: &'static str) {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "realtime.subscription_evicted",
|
||||||
|
reason = reason,
|
||||||
|
caller_id = %caller_id,
|
||||||
|
topic = %topic,
|
||||||
|
"🚫 realtime subscription evicted",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// Tests
|
// Tests
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+129
-4
@@ -28,6 +28,13 @@
|
|||||||
# one session → observe TWO `file_moved`
|
# one session → observe TWO `file_moved`
|
||||||
# events (one via the A topic, one via B).
|
# events (one via the A topic, one via B).
|
||||||
# Same file_id/from/to on both.
|
# Same file_id/from/to on both.
|
||||||
|
# S8 Grant-revoke eviction — user2 subscribes to A + B (both granted),
|
||||||
|
# user1 revokes only A → user2 sees
|
||||||
|
# `rt.revoked` for folder:A AND an event
|
||||||
|
# on folder:B (upload after revoke).
|
||||||
|
# Locks in three invariants: eviction
|
||||||
|
# fires, scoping is per-topic, session
|
||||||
|
# survives.
|
||||||
#
|
#
|
||||||
# Exit non-zero on any failure — run.sh treats that as a suite failure.
|
# Exit non-zero on any failure — run.sh treats that as a suite failure.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -123,26 +130,42 @@ user2_login=$(c_post "$base_url/api/auth/login" "" \
|
|||||||
"$(printf '{"username":"%s","password":"%s"}' "$user2_name" "$user2_pass")")
|
"$(printf '{"username":"%s","password":"%s"}' "$user2_name" "$user2_pass")")
|
||||||
user2_token=$(printf '%s' "$user2_login" | jq -r '.access_token')
|
user2_token=$(printf '%s' "$user2_login" | jq -r '.access_token')
|
||||||
[[ -n "$user2_token" && "$user2_token" != "null" ]] || die "no user2 token: $user2_login"
|
[[ -n "$user2_token" && "$user2_token" != "null" ]] || die "no user2 token: $user2_login"
|
||||||
|
# S8 needs user2's UUID to target them as the grant subject.
|
||||||
|
user2_id=$(printf '%s' "$user2_login" | jq -r '.user.full.user.id')
|
||||||
|
[[ -n "$user2_id" && "$user2_id" != "null" ]] || die "no user2 id: $user2_login"
|
||||||
|
|
||||||
# ── Helper: create a small file inside a folder via the byte-upload path.
|
# ── Helper: create a small file inside a folder via the byte-upload path.
|
||||||
# Not delta / instant-upload; keeps the wire simple and hits the same
|
# Not delta / instant-upload; keeps the wire simple and hits the same
|
||||||
# `upload_file_streaming` publish hook.
|
# `upload_file_streaming` publish hook.
|
||||||
mkfile_in() {
|
mkfile_in() {
|
||||||
local folder_id="$1" name="$2" token="$3"
|
local folder_id="$1" name="$2" token="$3"
|
||||||
local tmpfile
|
local tmpfile respfile status
|
||||||
tmpfile="$(mktemp -t rtbus_body.XXXXXX)"
|
tmpfile="$(mktemp -t rtbus_body.XXXXXX)"
|
||||||
|
respfile="$(mktemp -t rtbus_resp.XXXXXX)"
|
||||||
printf 'rt-bus-test-payload' > "$tmpfile"
|
printf 'rt-bus-test-payload' > "$tmpfile"
|
||||||
# Multipart-upload path used by the frontend for byte uploads.
|
# Multipart-upload path used by the frontend for byte uploads.
|
||||||
# NOTE: `folder_id` MUST come BEFORE the `file` part — file_handler.rs
|
# NOTE: `folder_id` MUST come BEFORE the `file` part — file_handler.rs
|
||||||
# streams the parts in order and the fail-fast folder-required check
|
# streams the parts in order and the fail-fast folder-required check
|
||||||
# fires the moment it sees the file bytes; a folder_id sent after the
|
# fires the moment it sees the file bytes; a folder_id sent after the
|
||||||
# file arrives too late (returns 400 "folder_id is required").
|
# file arrives too late (returns 400 "folder_id is required").
|
||||||
curl -sS -X POST \
|
#
|
||||||
|
# Capture body + status so a silent 4xx doesn't look like a timing
|
||||||
|
# bug in the bus. A stale server binary that lost the publish hook,
|
||||||
|
# or a schema change that broke the endpoint, would otherwise
|
||||||
|
# present as "subscribe works, no event, timeout" — exactly the
|
||||||
|
# shape of a real regression but a completely different root cause.
|
||||||
|
status=$(curl -sS -o "$respfile" -w "%{http_code}" -X POST \
|
||||||
-H "Authorization: Bearer $token" \
|
-H "Authorization: Bearer $token" \
|
||||||
-F "folder_id=$folder_id" \
|
-F "folder_id=$folder_id" \
|
||||||
-F "file=@$tmpfile;filename=$name" \
|
-F "file=@$tmpfile;filename=$name" \
|
||||||
"$base_url/api/files/upload" > /dev/null
|
"$base_url/api/files/upload")
|
||||||
rm -f "$tmpfile"
|
rm -f "$tmpfile"
|
||||||
|
if [[ "$status" -lt 200 || "$status" -ge 300 ]]; then
|
||||||
|
printf 'mkfile_in FAIL: HTTP %s\nbody: %s\n' "$status" "$(cat "$respfile")" >&2
|
||||||
|
rm -f "$respfile"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$respfile"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Scenario 1 — Positive delivery ──────────────────────────────────────────
|
# ── Scenario 1 — Positive delivery ──────────────────────────────────────────
|
||||||
@@ -371,4 +394,106 @@ if ! jq -e --arg fid "$s7_file_id" --arg from "$folder_a" --arg to "$folder_b" \
|
|||||||
fi
|
fi
|
||||||
log "S7 OK"
|
log "S7 OK"
|
||||||
|
|
||||||
log "All seven realtime-bus scenarios passed."
|
# ── Scenario 8 — Grant-revocation eviction (scoped, session survives) ───────
|
||||||
|
# The strong version of "eviction fires": prove that revoking one grant
|
||||||
|
# affects ONLY the corresponding subscription — the session stays alive,
|
||||||
|
# unrelated subs keep delivering events, and only the revoked topic
|
||||||
|
# gets `rt.revoked`.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# - user1 grants user2 `viewer` on folders A AND B (two independent
|
||||||
|
# grants; user2 has no prior access).
|
||||||
|
# - user2 subscribes to BOTH folders on one WS session.
|
||||||
|
# Action:
|
||||||
|
# - user1 revokes the grant on folder A only.
|
||||||
|
# - user1 uploads a file to folder B (the surviving sub).
|
||||||
|
# Invariants:
|
||||||
|
# (a) helper output records exactly ONE `rt.revoked` for `folder:A`
|
||||||
|
# with reason `grant_revoked` — the eviction fired.
|
||||||
|
# (b) helper output records exactly ONE `file_created` event for
|
||||||
|
# `folder:B` — the unrelated sub is still delivering. Regression
|
||||||
|
# that mass-drops subs on any AuthzChanged would surface as 0
|
||||||
|
# events.
|
||||||
|
# (c) `subscribed` contains BOTH folder:A and folder:B — both
|
||||||
|
# original subs were installed (regression that failed the initial
|
||||||
|
# subscribe under AuthZ would fail here).
|
||||||
|
# (d) `timed_out == false` — session actor kept running through the
|
||||||
|
# revoke + subsequent event. Regression that killed the whole
|
||||||
|
# session on AuthzChanged would surface as a timeout or the
|
||||||
|
# helper's `wait` failing.
|
||||||
|
log "S8: revoke user2's grant on folder A; unrelated sub on B still delivers."
|
||||||
|
# 8.1 Grant user2 viewer role on folder A + folder B.
|
||||||
|
grant_a=$(c_post "$base_url/api/grants" "$user1_token" \
|
||||||
|
"$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \
|
||||||
|
"$user2_id" "$folder_a")")
|
||||||
|
grant_a_id=$(printf '%s' "$grant_a" | jq -r '.grants[0].id')
|
||||||
|
[[ -n "$grant_a_id" && "$grant_a_id" != "null" ]] \
|
||||||
|
|| die "S8: grant on folder A failed: $grant_a"
|
||||||
|
|
||||||
|
grant_b=$(c_post "$base_url/api/grants" "$user1_token" \
|
||||||
|
"$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \
|
||||||
|
"$user2_id" "$folder_b")")
|
||||||
|
grant_b_id=$(printf '%s' "$grant_b" | jq -r '.grants[0].id')
|
||||||
|
[[ -n "$grant_b_id" && "$grant_b_id" != "null" ]] \
|
||||||
|
|| die "S8: grant on folder B failed: $grant_b"
|
||||||
|
|
||||||
|
# 8.2 user2 subscribes to BOTH folder topics; --expect-events 1 exits
|
||||||
|
# when the post-revoke upload lands on the SURVIVING sub.
|
||||||
|
out_s8="$(mktemp -t rtbus_s8.XXXXXX)"
|
||||||
|
"$HELPER_BIN" subscribe-and-collect \
|
||||||
|
--url "$ws_url" \
|
||||||
|
--token "$user2_token" \
|
||||||
|
--subscribe "folder:$folder_a" \
|
||||||
|
--subscribe "folder:$folder_b" \
|
||||||
|
--expect-events 1 \
|
||||||
|
--timeout 6s \
|
||||||
|
--output "$out_s8" &
|
||||||
|
helper_pid=$!
|
||||||
|
sleep 0.4 # let both subscribes install
|
||||||
|
|
||||||
|
# 8.3 user1 revokes only the folder-A grant.
|
||||||
|
curl -sS -X DELETE \
|
||||||
|
-H "Authorization: Bearer $user1_token" \
|
||||||
|
"$base_url/api/grants/$grant_a_id" > /dev/null
|
||||||
|
sleep 0.3 # let AuthzChanged propagate
|
||||||
|
|
||||||
|
# 8.4 user1 uploads to folder B → triggers file_created on the
|
||||||
|
# surviving sub.
|
||||||
|
mkfile_in "$folder_b" "s8.txt" "$user1_token"
|
||||||
|
|
||||||
|
if ! wait "$helper_pid"; then
|
||||||
|
cat "$out_s8" >&2 || true
|
||||||
|
die "S8: helper did not observe the post-revoke event on folder B"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8.5 Assert on the four invariants.
|
||||||
|
# (a) One rt.revoked for folder:A with reason grant_revoked.
|
||||||
|
revoked_count=$(jq -r '.revoked | length' "$out_s8")
|
||||||
|
[[ "$revoked_count" == "1" ]] \
|
||||||
|
|| { cat "$out_s8"; die "S8: expected 1 rt.revoked, got $revoked_count"; }
|
||||||
|
[[ "$(jq -r '.revoked[0].topic' "$out_s8")" == "folder:$folder_a" ]] \
|
||||||
|
|| die "S8: revoked wrong topic: $(jq -r '.revoked[0].topic' "$out_s8")"
|
||||||
|
[[ "$(jq -r '.revoked[0].reason' "$out_s8")" == "grant_revoked" ]] \
|
||||||
|
|| die "S8: revoked wrong reason: $(jq -r '.revoked[0].reason' "$out_s8")"
|
||||||
|
|
||||||
|
# (b) One file_created for folder:B — surviving sub delivered.
|
||||||
|
event_count=$(jq -r '.events | length' "$out_s8")
|
||||||
|
[[ "$event_count" == "1" ]] \
|
||||||
|
|| { cat "$out_s8"; die "S8: expected 1 event on surviving sub, got $event_count (regression: mass eviction?)"; }
|
||||||
|
[[ "$(jq -r '.events[0].event' "$out_s8")" == "file_created" ]] \
|
||||||
|
|| die "S8: wrong event kind on surviving sub"
|
||||||
|
[[ "$(jq -r '.events[0].data.parent_id' "$out_s8")" == "$folder_b" ]] \
|
||||||
|
|| die "S8: wrong parent_id on surviving-sub event"
|
||||||
|
|
||||||
|
# (c) Both original subs were installed.
|
||||||
|
sub_count=$(jq -r '.subscribed | length' "$out_s8")
|
||||||
|
[[ "$sub_count" == "2" ]] \
|
||||||
|
|| { cat "$out_s8"; die "S8: expected both subs installed, got $sub_count"; }
|
||||||
|
|
||||||
|
# (d) Session did not time out — main loop kept running.
|
||||||
|
[[ "$(jq -r '.timed_out' "$out_s8")" == "false" ]] \
|
||||||
|
|| die "S8: session timed out (regression: session died on AuthzChanged?)"
|
||||||
|
|
||||||
|
log "S8 OK"
|
||||||
|
|
||||||
|
log "All eight realtime-bus scenarios passed."
|
||||||
|
|||||||
Reference in New Issue
Block a user