feat(v1.1.8): F3 realtime auth_mode = 'session' (migration 0033)
Migration 0033 widens topics.auth_mode CHECK to include 'session'
alongside 'public' and 'token'.
TopicAuthMode enum gains a Session variant (as_str + from_db
extended uniformly).
RealtimeAuthorityImpl now takes Arc<dyn UsersService> as a third
constructor arg. The Session branch of authorize_subscribe
delegates to UsersService::verify_session_for_realtime(app_id,
token):
* Returns Some(user) → allow. The service bumps the sliding TTL
on success.
* Returns None → Unauthorized.
* Defense-in-depth: even though verify_session_for_realtime
already enforces cross-app isolation, the branch re-checks
user.app_id == app_id.
Tests added (4 new cases): valid session token allows; missing
token is Unauthorized; wrong token is Unauthorized; cross-app
session token is Unauthorized. All 12 realtime_authority tests
pass.
Dashboard: TopicAuthMode TypeScript union widened to include
'session'; the topic create + edit forms gain a third radio option
labeled "session — requires a per-app user session minted by
users::login (v1.1.8)".
picloud binary: construction order reshuffled so users is built
before realtime_authority. app_secrets_repo is now .clone()'d into
the pubsub realtime wiring so the original Arc can be re-used by
realtime_authority.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- v1.1.8 F3 — extend topics.auth_mode CHECK to allow 'session'.
|
||||
--
|
||||
-- v1.1.6 shipped 'public' + 'token'. v1.1.8 adds 'session' so a
|
||||
-- topic can authorize SSE subscribers against a per-app user session
|
||||
-- minted by users::login. The realtime authority delegates verify to
|
||||
-- UsersService::verify_session_for_realtime, which returns the user
|
||||
-- on success; the subscription proceeds with that principal.
|
||||
--
|
||||
-- 'script' (v1.2 script-mediated subscribe auth) extends the
|
||||
-- constraint a third time later.
|
||||
|
||||
ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_auth_mode_check;
|
||||
ALTER TABLE topics ADD CONSTRAINT topics_auth_mode_check
|
||||
CHECK (auth_mode IN ('public', 'token', 'session'));
|
||||
@@ -22,7 +22,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied};
|
||||
use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied, UsersService};
|
||||
|
||||
use crate::app_secrets_repo::AppSecretsRepo;
|
||||
use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
||||
@@ -30,15 +30,21 @@ use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
||||
pub struct RealtimeAuthorityImpl {
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
users: Arc<dyn UsersService>,
|
||||
key_cache: Mutex<HashMap<AppId, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl RealtimeAuthorityImpl {
|
||||
#[must_use]
|
||||
pub fn new(topics: Arc<dyn TopicRepo>, secrets: Arc<dyn AppSecretsRepo>) -> Self {
|
||||
pub fn new(
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
users: Arc<dyn UsersService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
topics,
|
||||
secrets,
|
||||
users,
|
||||
key_cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
@@ -104,6 +110,28 @@ impl RealtimeAuthority for RealtimeAuthorityImpl {
|
||||
}
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +212,7 @@ mod tests {
|
||||
RealtimeAuthorityImpl::new(
|
||||
Arc::new(FakeTopics(topics)),
|
||||
Arc::new(FakeSecrets(key_app, key)),
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -308,6 +337,304 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[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),
|
||||
}),
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -13,14 +13,16 @@ use picloud_shared::AppId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// External-subscriber auth gate for a topic. `'public'` + `'token'` in
|
||||
/// v1.1.6; `'session'` (v1.1.8) and `'script'` (v1.2) extend the DB
|
||||
/// CHECK constraint and this enum later.
|
||||
/// External-subscriber auth gate for a topic. v1.1.6 shipped
|
||||
/// `'public'` + `'token'`; v1.1.8 F3 adds `'session'` (per-app user
|
||||
/// sessions authorize subscription); `'script'` (v1.2) extends the
|
||||
/// DB CHECK constraint and this enum later.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TopicAuthMode {
|
||||
Public,
|
||||
Token,
|
||||
Session,
|
||||
}
|
||||
|
||||
impl TopicAuthMode {
|
||||
@@ -29,6 +31,7 @@ impl TopicAuthMode {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Token => "token",
|
||||
Self::Session => "session",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +39,7 @@ impl TopicAuthMode {
|
||||
match s {
|
||||
"public" => Ok(Self::Public),
|
||||
"token" => Ok(Self::Token),
|
||||
"session" => Ok(Self::Session),
|
||||
other => Err(TopicRepoError::Backend(format!(
|
||||
"unknown auth_mode in DB: {other}"
|
||||
))),
|
||||
|
||||
Reference in New Issue
Block a user