feat(realtime): external SSE subscription for group shared topics (§11.6 D2 / Track A M6)
Shared TOPICS fanned out only to in-cluster trigger handlers; external clients
could not subscribe (per-app topics already can). Add SSE for shared topics.
- RealtimeBroadcaster gains a parallel (group_id, topic) channel map:
subscribe_group / publish_group / drop_group_topic (default no-ops so
NoopRealtimeBroadcaster + test doubles are untouched). InProcessBroadcaster
implements them with a second map; GC + channel_count span both.
- Route GET /realtime/shared/topics/{topic}: Host->app dispatch (as the per-app
route), then RealtimeAuthority::authorize_subscribe_shared resolves the OWNING
GROUP from the app's chain (kind=topic, root segment). Reads-open model — the
resolution IS the authorization, consistent with in-script shared reads; a
foreign-subtree app never resolves (404, the isolation boundary). No principal
machinery needed.
- GroupPubsubServiceImpl::with_realtime bridges a shared-topic publish to the
owning-group channel (best-effort) after the durable trigger fan-out.
- Host wires the broadcaster into the group pubsub service + the collection
resolver into the authority.
Auth-model note: chose reads-open (subtree app's Host is the grant) over
"authenticated principal + GroupKvRead" — it's both simpler and faithful to how
shared-collection reads already work. Pinned by realtime broadcaster group-map
tests, realtime_api shared-route tests (404 + stream), and
group_pubsub_service::publish_bridges_to_the_group_broadcaster. No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,11 +22,18 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied, UsersService};
|
||||
use picloud_shared::{
|
||||
subscriber_token, AppId, GroupId, RealtimeAuthority, SubscribeDenied, UsersService,
|
||||
};
|
||||
|
||||
use crate::app_secrets_repo::AppSecretsRepo;
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
||||
|
||||
/// The registry `kind` a shared topic is declared under (mirrors
|
||||
/// `group_pubsub_service::KIND_TOPIC`).
|
||||
const KIND_TOPIC: &str = "topic";
|
||||
|
||||
/// F-S-008: TTL on cached signing-key entries. Once key rotation
|
||||
/// lands, every running process keeps accepting tokens signed by the
|
||||
/// old key until restart — bounded eviction is the simplest defence.
|
||||
@@ -37,6 +44,9 @@ pub struct RealtimeAuthorityImpl {
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
users: Arc<dyn UsersService>,
|
||||
/// §11.6 D2: resolves a shared-topic name to its owning group on the
|
||||
/// subscriber app's ancestor chain (the isolation boundary).
|
||||
collections: Arc<dyn GroupCollectionResolver>,
|
||||
key_cache: Mutex<HashMap<AppId, (std::time::Instant, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
@@ -46,11 +56,13 @@ impl RealtimeAuthorityImpl {
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
users: Arc<dyn UsersService>,
|
||||
collections: Arc<dyn GroupCollectionResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
topics,
|
||||
secrets,
|
||||
users,
|
||||
collections,
|
||||
key_cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
@@ -152,6 +164,24 @@ impl RealtimeAuthority for RealtimeAuthorityImpl {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize_subscribe_shared(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
topic: &str,
|
||||
) -> Result<GroupId, SubscribeDenied> {
|
||||
// The declared collection is the topic's ROOT segment (`events.created`
|
||||
// → `events`), matching the publish-side validation. Resolving it against
|
||||
// the subscriber app's chain (kind=`topic`) is both the existence check
|
||||
// and the authorization — reads are open to the subtree; a foreign app's
|
||||
// chain never reaches the owning group, so it 404s (isolation boundary).
|
||||
let root = topic.split('.').next().unwrap_or(topic);
|
||||
self.collections
|
||||
.resolve_owning_group(app_id, root, KIND_TOPIC)
|
||||
.await
|
||||
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?
|
||||
.ok_or(SubscribeDenied::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -222,6 +252,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fake shared-topic resolver: `Some((declared_name, owning_group))` resolves
|
||||
/// that one topic name (kind=topic) to the group; everything else → None.
|
||||
#[derive(Default)]
|
||||
struct FakeCollections(Option<(String, GroupId)>);
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeCollections {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self
|
||||
.0
|
||||
.as_ref()
|
||||
.filter(|(n, _)| n == name && kind == KIND_TOPIC)
|
||||
.map(|(_, g)| *g))
|
||||
}
|
||||
}
|
||||
|
||||
fn authority(
|
||||
topics: Vec<(AppId, Topic)>,
|
||||
key_app: AppId,
|
||||
@@ -231,9 +281,32 @@ mod tests {
|
||||
Arc::new(FakeTopics(topics)),
|
||||
Arc::new(FakeSecrets(key_app, key)),
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(FakeCollections::default()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_topic_resolves_owning_group_or_404() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let auth = RealtimeAuthorityImpl::new(
|
||||
Arc::new(FakeTopics(vec![])),
|
||||
Arc::new(FakeSecrets(app, vec![0u8; 32])),
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(FakeCollections(Some(("events".into(), group)))),
|
||||
);
|
||||
// The full topic's ROOT segment resolves; returns the OWNING group.
|
||||
assert_eq!(
|
||||
auth.authorize_subscribe_shared(app, "events.created").await,
|
||||
Ok(group)
|
||||
);
|
||||
// An undeclared topic name → NotFound (the isolation/existence boundary).
|
||||
assert_eq!(
|
||||
auth.authorize_subscribe_shared(app, "secrets").await,
|
||||
Err(SubscribeDenied::NotFound)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_topic_is_not_found() {
|
||||
let app = AppId::new();
|
||||
@@ -590,6 +663,7 @@ mod tests {
|
||||
app_id,
|
||||
user: stub_user(app_id),
|
||||
}),
|
||||
Arc::new(FakeCollections::default()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -652,6 +726,7 @@ mod tests {
|
||||
app_id: app_a,
|
||||
user: stub_user(app_a),
|
||||
}),
|
||||
Arc::new(FakeCollections::default()),
|
||||
);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app_b, "chat", Some("app-a-token"))
|
||||
|
||||
Reference in New Issue
Block a user