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:
MechaCat02
2026-06-06 15:00:47 +02:00
parent 3c2c4a3767
commit ff4f443531
6 changed files with 374 additions and 12 deletions

View File

@@ -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'));

View File

@@ -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();

View File

@@ -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}"
))),