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>
766 lines
25 KiB
Rust
766 lines
25 KiB
Rust
//! `RealtimeAuthorityImpl` — the DB-backed SSE subscribe gate (v1.1.6).
|
|
//!
|
|
//! Backs the [`picloud_shared::RealtimeAuthority`] trait the SSE handler
|
|
//! in orchestrator-core calls. All `topics`-table reads and signing-key
|
|
//! material stay inside this impl so the data-plane crate never touches
|
|
//! the key.
|
|
//!
|
|
//! Verdict mapping (see [`SubscribeDenied`]):
|
|
//! * topic missing OR not externally subscribable → `NotFound` (404).
|
|
//! Both collapse to 404 so the endpoint can't probe internal topics.
|
|
//! * `auth_mode = 'public'` → allow.
|
|
//! * `auth_mode = 'token'` → verify the HMAC token (present, signed by
|
|
//! this app's key, unexpired, scoped to this topic) → allow, else
|
|
//! `Unauthorized` (401, generic — never says which check failed).
|
|
//!
|
|
//! Signing keys never change in v1.1.6 (no rotation API), so a small
|
|
//! in-memory cache avoids a per-subscribe DB read once an app's key has
|
|
//! been seen. The cache is purely an optimization — a cold miss reads
|
|
//! the row.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
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.
|
|
/// Also bounds memory growth on a many-app install.
|
|
const KEY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
|
|
|
|
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>)>>,
|
|
}
|
|
|
|
impl RealtimeAuthorityImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
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()),
|
|
}
|
|
}
|
|
|
|
/// Fetch the app's signing key, consulting the cache first. Returns
|
|
/// `None` when the app has no key (no token ever minted) — which the
|
|
/// caller maps to `Unauthorized`.
|
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, SubscribeDenied> {
|
|
if let Ok(cache) = self.key_cache.lock() {
|
|
if let Some((ts, k)) = cache.get(&app_id) {
|
|
if ts.elapsed() <= KEY_CACHE_TTL {
|
|
return Ok(Some(k.clone()));
|
|
}
|
|
}
|
|
}
|
|
let key = self
|
|
.secrets
|
|
.signing_key(app_id)
|
|
.await
|
|
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
|
if let Some(ref k) = key {
|
|
if let Ok(mut cache) = self.key_cache.lock() {
|
|
cache.insert(app_id, (std::time::Instant::now(), k.clone()));
|
|
}
|
|
}
|
|
Ok(key)
|
|
}
|
|
|
|
/// F-S-008 + future key-rotation hook: drop the cached signing key
|
|
/// for an app. Wired into the app-secrets rotation path so a fresh
|
|
/// key takes effect on the next request instead of waiting for the
|
|
/// TTL.
|
|
pub fn invalidate_signing_key(&self, app_id: AppId) {
|
|
if let Ok(mut cache) = self.key_cache.lock() {
|
|
cache.remove(&app_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RealtimeAuthority for RealtimeAuthorityImpl {
|
|
async fn authorize_subscribe(
|
|
&self,
|
|
app_id: AppId,
|
|
topic: &str,
|
|
token: Option<&str>,
|
|
) -> Result<(), SubscribeDenied> {
|
|
let registered = self
|
|
.topics
|
|
.get(app_id, topic)
|
|
.await
|
|
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
|
|
|
// Missing topic AND internal-only topic both 404 — don't leak
|
|
// which internal topics exist.
|
|
let Some(t) = registered.filter(|t| t.external_subscribable) else {
|
|
return Err(SubscribeDenied::NotFound);
|
|
};
|
|
|
|
match t.auth_mode {
|
|
TopicAuthMode::Public => Ok(()),
|
|
TopicAuthMode::Token => {
|
|
let token = token.ok_or(SubscribeDenied::Unauthorized)?;
|
|
let key = self
|
|
.signing_key(app_id)
|
|
.await?
|
|
.ok_or(SubscribeDenied::Unauthorized)?;
|
|
let now = chrono::Utc::now().timestamp();
|
|
let claims = subscriber_token::verify(&key, token, now)
|
|
.map_err(|_| SubscribeDenied::Unauthorized)?;
|
|
// Per-app key already makes a cross-app token fail the
|
|
// signature check; this is belt-and-suspenders.
|
|
if claims.app_id != app_id || !claims.allows_topic(topic) {
|
|
return Err(SubscribeDenied::Unauthorized);
|
|
}
|
|
Ok(())
|
|
}
|
|
TopicAuthMode::Session => {
|
|
// v1.1.8 F3: per-app user session authorizes the
|
|
// subscribe. The token comes off the
|
|
// `Authorization: Bearer ...` header or `?token=...`
|
|
// query (extraction is on the SSE handler side,
|
|
// unchanged from the Token branch). UsersService
|
|
// bumps the sliding TTL on success and rejects
|
|
// cross-app tokens internally.
|
|
let token = token.ok_or(SubscribeDenied::Unauthorized)?;
|
|
let user = self
|
|
.users
|
|
.verify_session_for_realtime(app_id, token)
|
|
.await
|
|
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?
|
|
.ok_or(SubscribeDenied::Unauthorized)?;
|
|
// Defense-in-depth: the service already cross-app
|
|
// checks, but if the impl drifted we still reject.
|
|
if user.app_id != app_id {
|
|
return Err(SubscribeDenied::Unauthorized);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
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)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app_secrets_repo::AppSecretsRepoError;
|
|
use crate::topic_repo::{Topic, TopicRepoError};
|
|
use chrono::Utc;
|
|
use picloud_shared::subscriber_token::{sign, TokenClaims};
|
|
|
|
struct FakeTopics(Vec<(AppId, Topic)>);
|
|
#[async_trait]
|
|
impl TopicRepo for FakeTopics {
|
|
async fn create(
|
|
&self,
|
|
_: AppId,
|
|
_: &str,
|
|
_: bool,
|
|
_: TopicAuthMode,
|
|
) -> Result<Topic, TopicRepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list(&self, _: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
|
Ok(self
|
|
.0
|
|
.iter()
|
|
.find(|(a, t)| *a == app_id && t.name == name)
|
|
.map(|(_, t)| t.clone()))
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_: AppId,
|
|
_: &str,
|
|
_: Option<bool>,
|
|
_: Option<TopicAuthMode>,
|
|
) -> Result<Option<Topic>, TopicRepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(&self, _: AppId, _: &str) -> Result<bool, TopicRepoError> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
struct FakeSecrets(AppId, Vec<u8>);
|
|
#[async_trait]
|
|
impl AppSecretsRepo for FakeSecrets {
|
|
async fn get_or_create_signing_key(
|
|
&self,
|
|
_: AppId,
|
|
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
|
Ok(self.1.clone())
|
|
}
|
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
|
Ok((app_id == self.0).then(|| self.1.clone()))
|
|
}
|
|
}
|
|
|
|
fn topic(name: &str, external: bool, mode: TopicAuthMode) -> Topic {
|
|
Topic {
|
|
name: name.to_string(),
|
|
external_subscribable: external,
|
|
auth_mode: mode,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
key: Vec<u8>,
|
|
) -> RealtimeAuthorityImpl {
|
|
RealtimeAuthorityImpl::new(
|
|
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();
|
|
let a = authority(vec![], app, vec![0u8; 32]);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "ghost", None).await,
|
|
Err(SubscribeDenied::NotFound)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn internal_only_topic_is_not_found() {
|
|
let app = AppId::new();
|
|
let a = authority(
|
|
vec![(app, topic("internal", false, TopicAuthMode::Public))],
|
|
app,
|
|
vec![0u8; 32],
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "internal", None).await,
|
|
Err(SubscribeDenied::NotFound)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn public_topic_allows_without_token() {
|
|
let app = AppId::new();
|
|
let a = authority(
|
|
vec![(app, topic("news", true, TopicAuthMode::Public))],
|
|
app,
|
|
vec![0u8; 32],
|
|
);
|
|
assert!(a.authorize_subscribe(app, "news", None).await.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_topic_without_token_is_unauthorized() {
|
|
let app = AppId::new();
|
|
let a = authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
|
app,
|
|
vec![7u8; 32],
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "chat", None).await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_topic_with_valid_token_allows() {
|
|
let app = AppId::new();
|
|
let key = vec![9u8; 32];
|
|
let a = authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
|
app,
|
|
key.clone(),
|
|
);
|
|
let token = sign(
|
|
&key,
|
|
&TokenClaims {
|
|
app_id: app,
|
|
topics: vec!["chat".into()],
|
|
iat: Utc::now().timestamp(),
|
|
exp: Utc::now().timestamp() + 60,
|
|
},
|
|
);
|
|
assert!(a
|
|
.authorize_subscribe(app, "chat", Some(&token))
|
|
.await
|
|
.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_for_other_topic_is_unauthorized() {
|
|
let app = AppId::new();
|
|
let key = vec![9u8; 32];
|
|
let a = authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
|
app,
|
|
key.clone(),
|
|
);
|
|
let token = sign(
|
|
&key,
|
|
&TokenClaims {
|
|
app_id: app,
|
|
topics: vec!["other".into()],
|
|
iat: Utc::now().timestamp(),
|
|
exp: Utc::now().timestamp() + 60,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "chat", Some(&token)).await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_token_is_unauthorized() {
|
|
let app = AppId::new();
|
|
let key = vec![9u8; 32];
|
|
let a = authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
|
app,
|
|
key.clone(),
|
|
);
|
|
let token = sign(
|
|
&key,
|
|
&TokenClaims {
|
|
app_id: app,
|
|
topics: vec!["chat".into()],
|
|
iat: Utc::now().timestamp() - 120,
|
|
exp: Utc::now().timestamp() - 60,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "chat", Some(&token)).await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
|
|
/// Fake UsersService that resolves a specific (token, app_id) pair
|
|
/// into a stubbed AppUser. All other methods return Backend
|
|
/// errors via NoopUsersService — only verify_session_for_realtime
|
|
/// is exercised by these tests.
|
|
struct FakeUsersForRealtime {
|
|
token: String,
|
|
app_id: AppId,
|
|
user: picloud_shared::AppUser,
|
|
}
|
|
#[async_trait]
|
|
impl UsersService for FakeUsersForRealtime {
|
|
async fn verify_session_for_realtime(
|
|
&self,
|
|
app_id: AppId,
|
|
token: &str,
|
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
|
if app_id == self.app_id && token == self.token {
|
|
Ok(Some(self.user.clone()))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
async fn create(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::CreateUserInput,
|
|
) -> Result<picloud_shared::AppUser, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn find_by_email(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn email_available(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
) -> Result<bool, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
_: picloud_shared::UpdateUserInput,
|
|
) -> Result<picloud_shared::AppUser, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
) -> Result<bool, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::UsersListOpts,
|
|
) -> Result<picloud_shared::UsersListPage, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn login(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
_: &str,
|
|
) -> Result<Option<picloud_shared::GeneratedSession>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn verify(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn logout(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
) -> Result<(), picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn send_verification_email(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
_: picloud_shared::EmailTemplateOpts,
|
|
) -> Result<(), picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn verify_email(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn request_password_reset(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
_: picloud_shared::EmailTemplateOpts,
|
|
) -> Result<(), picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn complete_password_reset(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
_: &str,
|
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn invite(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
_: picloud_shared::InviteOpts,
|
|
) -> Result<(), picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn accept_invite(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: &str,
|
|
_: &str,
|
|
_: Option<String>,
|
|
) -> Result<Option<picloud_shared::GeneratedAccept>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn add_role(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
_: &str,
|
|
) -> Result<(), picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn remove_role(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
_: &str,
|
|
) -> Result<bool, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn has_role(
|
|
&self,
|
|
_: &picloud_shared::SdkCallCx,
|
|
_: picloud_shared::AppUserId,
|
|
_: &str,
|
|
) -> Result<bool, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn admin_reset_password_token(
|
|
&self,
|
|
_: &picloud_shared::Principal,
|
|
_: AppId,
|
|
_: picloud_shared::AppUserId,
|
|
) -> Result<String, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn admin_revoke_all_sessions(
|
|
&self,
|
|
_: &picloud_shared::Principal,
|
|
_: AppId,
|
|
_: picloud_shared::AppUserId,
|
|
) -> Result<u64, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn admin_create_invitation(
|
|
&self,
|
|
_: &picloud_shared::Principal,
|
|
_: AppId,
|
|
_: &str,
|
|
_: picloud_shared::InviteOpts,
|
|
) -> Result<picloud_shared::Invitation, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_invitations(
|
|
&self,
|
|
_: &picloud_shared::Principal,
|
|
_: AppId,
|
|
) -> Result<Vec<picloud_shared::Invitation>, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
async fn revoke_invitation(
|
|
&self,
|
|
_: &picloud_shared::Principal,
|
|
_: AppId,
|
|
_: picloud_shared::InvitationId,
|
|
) -> Result<bool, picloud_shared::UsersError> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
fn stub_user(app_id: AppId) -> picloud_shared::AppUser {
|
|
picloud_shared::AppUser {
|
|
id: picloud_shared::AppUserId::new(),
|
|
app_id,
|
|
email: "alice@example.com".into(),
|
|
display_name: Some("Alice".into()),
|
|
email_verified_at: None,
|
|
last_login_at: None,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
roles: vec![],
|
|
}
|
|
}
|
|
|
|
fn session_authority(
|
|
topics: Vec<(AppId, Topic)>,
|
|
app_id: AppId,
|
|
token: &str,
|
|
) -> RealtimeAuthorityImpl {
|
|
RealtimeAuthorityImpl::new(
|
|
Arc::new(FakeTopics(topics)),
|
|
Arc::new(FakeSecrets(app_id, vec![0u8; 32])),
|
|
Arc::new(FakeUsersForRealtime {
|
|
token: token.to_string(),
|
|
app_id,
|
|
user: stub_user(app_id),
|
|
}),
|
|
Arc::new(FakeCollections::default()),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_topic_with_valid_session_token_allows() {
|
|
let app = AppId::new();
|
|
let a = session_authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Session))],
|
|
app,
|
|
"valid-session-token",
|
|
);
|
|
assert!(a
|
|
.authorize_subscribe(app, "chat", Some("valid-session-token"))
|
|
.await
|
|
.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_topic_without_token_is_unauthorized() {
|
|
let app = AppId::new();
|
|
let a = session_authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Session))],
|
|
app,
|
|
"valid-session-token",
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "chat", None).await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_topic_with_wrong_token_is_unauthorized() {
|
|
let app = AppId::new();
|
|
let a = session_authority(
|
|
vec![(app, topic("chat", true, TopicAuthMode::Session))],
|
|
app,
|
|
"valid-session-token",
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app, "chat", Some("wrong-token"))
|
|
.await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_token_for_other_app_is_unauthorized() {
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
// Authority for app B; users service knows app A's token.
|
|
let a = RealtimeAuthorityImpl::new(
|
|
Arc::new(FakeTopics(vec![(
|
|
app_b,
|
|
topic("chat", true, TopicAuthMode::Session),
|
|
)])),
|
|
Arc::new(FakeSecrets(app_b, vec![0u8; 32])),
|
|
Arc::new(FakeUsersForRealtime {
|
|
token: "app-a-token".into(),
|
|
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"))
|
|
.await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_signed_by_other_app_key_is_unauthorized() {
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
let key_a = vec![1u8; 32];
|
|
let key_b = vec![2u8; 32];
|
|
// Authority for app B; its key is key_b.
|
|
let a = authority(
|
|
vec![(app_b, topic("chat", true, TopicAuthMode::Token))],
|
|
app_b,
|
|
key_b,
|
|
);
|
|
// Token signed by app A's key, claiming app A.
|
|
let token = sign(
|
|
&key_a,
|
|
&TokenClaims {
|
|
app_id: app_a,
|
|
topics: vec!["chat".into()],
|
|
iat: Utc::now().timestamp(),
|
|
exp: Utc::now().timestamp() + 60,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
a.authorize_subscribe(app_b, "chat", Some(&token)).await,
|
|
Err(SubscribeDenied::Unauthorized)
|
|
);
|
|
}
|
|
}
|