Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.
Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
(-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
(picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
to in-process subscribers after the durable outbox commit (best-effort,
panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
+ pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
badge, flip confirmation).
v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).
Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
630 lines
20 KiB
Rust
630 lines
20 KiB
Rust
//! `/api/v1/admin/apps/{id}/topics*` — topic registration admin
|
|
//! endpoints (v1.1.6).
|
|
//!
|
|
//! These manage the `topics` table: the explicit registry of which
|
|
//! pub/sub topics are externally subscribable over SSE (design notes
|
|
//! §5). Internal-only topics never appear here.
|
|
//!
|
|
//! * `POST /apps/{id}/topics` — register a topic.
|
|
//! * `GET /apps/{id}/topics` — list registered topics.
|
|
//! * `PATCH /apps/{id}/topics/{name}` — flip external/auth_mode.
|
|
//! * `DELETE /apps/{id}/topics/{name}` — unregister + disconnect.
|
|
//!
|
|
//! The PATCH endpoint is deliberately its OWN surface (not folded into a
|
|
//! generic topic update) so every change to externally-subscribable
|
|
//! status is a discrete, watchable/auditable API call (§5 commitment).
|
|
//!
|
|
//! Create / update / delete are gated by `AppTopicManage` (→ `app:admin`
|
|
//! scope); list is gated by the existing `AppRead`. DELETE also drops
|
|
//! the topic's in-process broadcast channel so live SSE subscribers
|
|
//! disconnect cleanly.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::routing::{get, patch};
|
|
use axum::{Extension, Router};
|
|
use picloud_shared::{AppId, Principal, RealtimeBroadcaster};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
|
|
use crate::app_repo::AppRepository;
|
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
|
use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
|
|
|
#[derive(Clone)]
|
|
pub struct TopicsState {
|
|
pub topics: Arc<dyn TopicRepo>,
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
pub broadcaster: Arc<dyn RealtimeBroadcaster>,
|
|
}
|
|
|
|
pub fn topics_router(state: TopicsState) -> Router {
|
|
Router::new()
|
|
.route("/apps/{app_id}/topics", get(list_topics).post(create_topic))
|
|
.route(
|
|
"/apps/{app_id}/topics/{name}",
|
|
patch(update_topic).delete(delete_topic),
|
|
)
|
|
.with_state(state)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateTopicRequest {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub external_subscribable: bool,
|
|
#[serde(default = "default_auth_mode")]
|
|
pub auth_mode: TopicAuthMode,
|
|
}
|
|
|
|
const fn default_auth_mode() -> TopicAuthMode {
|
|
TopicAuthMode::Public
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateTopicRequest {
|
|
#[serde(default)]
|
|
pub external_subscribable: Option<bool>,
|
|
#[serde(default)]
|
|
pub auth_mode: Option<TopicAuthMode>,
|
|
}
|
|
|
|
/// Topic names are concrete (external pattern subscription is v1.2), so
|
|
/// reject empties and `*` wildcards at registration.
|
|
fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> {
|
|
if name.trim().is_empty() {
|
|
return Err(TopicsApiError::Invalid(
|
|
"topic name must not be empty".into(),
|
|
));
|
|
}
|
|
if name.contains('*') {
|
|
return Err(TopicsApiError::Invalid(
|
|
"topic name must be a concrete topic, not a pattern (no '*')".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn create_topic(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreateTopicRequest>,
|
|
) -> Result<(StatusCode, Json<Topic>), TopicsApiError> {
|
|
ensure_app_exists(&*s.apps, app_id).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppTopicManage(app_id),
|
|
)
|
|
.await?;
|
|
validate_topic_name(&input.name)?;
|
|
let topic = s
|
|
.topics
|
|
.create(
|
|
app_id,
|
|
input.name.trim(),
|
|
input.external_subscribable,
|
|
input.auth_mode,
|
|
)
|
|
.await?;
|
|
Ok((StatusCode::CREATED, Json(topic)))
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct ListTopicsResponse {
|
|
topics: Vec<Topic>,
|
|
}
|
|
|
|
async fn list_topics(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
) -> Result<Json<ListTopicsResponse>, TopicsApiError> {
|
|
ensure_app_exists(&*s.apps, app_id).await?;
|
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?;
|
|
let topics = s.topics.list(app_id).await?;
|
|
Ok(Json(ListTopicsResponse { topics }))
|
|
}
|
|
|
|
async fn update_topic(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((app_id, name)): Path<(AppId, String)>,
|
|
Json(input): Json<UpdateTopicRequest>,
|
|
) -> Result<Json<Topic>, TopicsApiError> {
|
|
ensure_app_exists(&*s.apps, app_id).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppTopicManage(app_id),
|
|
)
|
|
.await?;
|
|
let topic = s
|
|
.topics
|
|
.update(app_id, &name, input.external_subscribable, input.auth_mode)
|
|
.await?
|
|
.ok_or(TopicsApiError::NotFound)?;
|
|
Ok(Json(topic))
|
|
}
|
|
|
|
async fn delete_topic(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((app_id, name)): Path<(AppId, String)>,
|
|
) -> Result<StatusCode, TopicsApiError> {
|
|
ensure_app_exists(&*s.apps, app_id).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppTopicManage(app_id),
|
|
)
|
|
.await?;
|
|
if !s.topics.delete(app_id, &name).await? {
|
|
return Err(TopicsApiError::NotFound);
|
|
}
|
|
// Disconnect any live SSE subscribers for the now-unregistered topic.
|
|
s.broadcaster.drop_topic(app_id, &name).await;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), TopicsApiError> {
|
|
apps.get_by_id(app_id)
|
|
.await
|
|
.map_err(|e| TopicsApiError::Backend(e.to_string()))?
|
|
.ok_or(TopicsApiError::AppNotFound)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TopicsApiError {
|
|
#[error("app not found")]
|
|
AppNotFound,
|
|
#[error("topic not found")]
|
|
NotFound,
|
|
#[error("{0}")]
|
|
AlreadyExists(String),
|
|
#[error("invalid request: {0}")]
|
|
Invalid(String),
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
#[error("topics backend: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
impl From<AuthzDenied> for TopicsApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AuthzError> for TopicsApiError {
|
|
fn from(e: AuthzError) -> Self {
|
|
Self::AuthzRepo(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<TopicRepoError> for TopicsApiError {
|
|
fn from(e: TopicRepoError) -> Self {
|
|
match e {
|
|
TopicRepoError::AlreadyExists(name) => {
|
|
Self::AlreadyExists(format!("a topic named {name:?} already exists in this app"))
|
|
}
|
|
other => Self::Backend(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for TopicsApiError {
|
|
fn into_response(self) -> Response {
|
|
let (status, body) = match &self {
|
|
Self::AppNotFound | Self::NotFound => {
|
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
|
}
|
|
Self::AlreadyExists(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
|
Self::Invalid(_) => (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
json!({ "error": self.to_string() }),
|
|
),
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "topics admin authz repo error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
Self::Backend(e) => {
|
|
tracing::error!(error = %e, "topics admin backend error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
//! In-memory handler tests: capability enforcement, the
|
|
//! `external_subscribable` default, the flip being its own endpoint,
|
|
//! cross-app isolation, and DELETE disconnecting subscribers. The
|
|
//! Postgres repo is exercised by the schema + integration suites.
|
|
|
|
use super::*;
|
|
use crate::repo::ScriptRepositoryError;
|
|
use async_trait::async_trait;
|
|
use chrono::Utc;
|
|
use picloud_shared::{
|
|
AdminUserId, App, AppRole, BroadcasterError, InstanceRole, RealtimeEvent, UserId,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex as StdMutex;
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryTopicRepo {
|
|
inner: Mutex<HashMap<(AppId, String), Topic>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TopicRepo for InMemoryTopicRepo {
|
|
async fn create(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
external_subscribable: bool,
|
|
auth_mode: TopicAuthMode,
|
|
) -> Result<Topic, TopicRepoError> {
|
|
let mut g = self.inner.lock().await;
|
|
if g.contains_key(&(app_id, name.to_string())) {
|
|
return Err(TopicRepoError::AlreadyExists(name.to_string()));
|
|
}
|
|
let now = Utc::now();
|
|
let t = Topic {
|
|
name: name.to_string(),
|
|
external_subscribable,
|
|
auth_mode,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
g.insert((app_id, name.to_string()), t.clone());
|
|
Ok(t)
|
|
}
|
|
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
|
let g = self.inner.lock().await;
|
|
let mut v: Vec<Topic> = g
|
|
.iter()
|
|
.filter(|((a, _), _)| *a == app_id)
|
|
.map(|(_, t)| t.clone())
|
|
.collect();
|
|
v.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(v)
|
|
}
|
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
|
Ok(self
|
|
.inner
|
|
.lock()
|
|
.await
|
|
.get(&(app_id, name.to_string()))
|
|
.cloned())
|
|
}
|
|
async fn update(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
external_subscribable: Option<bool>,
|
|
auth_mode: Option<TopicAuthMode>,
|
|
) -> Result<Option<Topic>, TopicRepoError> {
|
|
let mut g = self.inner.lock().await;
|
|
let Some(t) = g.get_mut(&(app_id, name.to_string())) else {
|
|
return Ok(None);
|
|
};
|
|
if let Some(e) = external_subscribable {
|
|
t.external_subscribable = e;
|
|
}
|
|
if let Some(m) = auth_mode {
|
|
t.auth_mode = m;
|
|
}
|
|
t.updated_at = Utc::now();
|
|
Ok(Some(t.clone()))
|
|
}
|
|
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError> {
|
|
Ok(self
|
|
.inner
|
|
.lock()
|
|
.await
|
|
.remove(&(app_id, name.to_string()))
|
|
.is_some())
|
|
}
|
|
}
|
|
|
|
struct InMemoryAppRepo(AppId);
|
|
#[async_trait]
|
|
impl AppRepository for InMemoryAppRepo {
|
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
|
if id != self.0 {
|
|
return Ok(None);
|
|
}
|
|
let now = Utc::now();
|
|
Ok(Some(App {
|
|
id,
|
|
slug: "test".into(),
|
|
name: "test".into(),
|
|
description: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
}))
|
|
}
|
|
async fn get_by_slug(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_by_slug_or_history(
|
|
&self,
|
|
_: &str,
|
|
) -> Result<Option<crate::app_repo::AppLookup>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn slug_in_history(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: Option<&str>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create_with_takeover(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: Option<&str>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_: AppId,
|
|
_: Option<&str>,
|
|
_: Option<Option<&str>>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn rename_slug(
|
|
&self,
|
|
_: AppId,
|
|
_: &str,
|
|
_: bool,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete_cascade(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn count_scripts_in_app(&self, _: AppId) -> Result<i64, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
/// Grants `AppAdmin` only for `granted_app`; denies elsewhere — used
|
|
/// for the cross-app isolation test.
|
|
struct PerAppAuthzRepo {
|
|
granted_app: AppId,
|
|
}
|
|
#[async_trait]
|
|
impl AuthzRepo for PerAppAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok((app_id == self.granted_app).then_some(AppRole::AppAdmin))
|
|
}
|
|
}
|
|
|
|
struct DenyAuthzRepo;
|
|
#[async_trait]
|
|
impl AuthzRepo for DenyAuthzRepo {
|
|
async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingBroadcaster {
|
|
dropped: StdMutex<Vec<(AppId, String)>>,
|
|
}
|
|
#[async_trait]
|
|
impl RealtimeBroadcaster for RecordingBroadcaster {
|
|
async fn subscribe(
|
|
&self,
|
|
_: AppId,
|
|
_: &str,
|
|
) -> Result<tokio::sync::broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
|
unimplemented!()
|
|
}
|
|
async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {}
|
|
async fn drop_topic(&self, app_id: AppId, topic: &str) {
|
|
self.dropped
|
|
.lock()
|
|
.unwrap()
|
|
.push((app_id, topic.to_string()));
|
|
}
|
|
}
|
|
|
|
fn member() -> Principal {
|
|
Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Member,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}
|
|
}
|
|
|
|
fn state(app_id: AppId, authz: Arc<dyn AuthzRepo>) -> (TopicsState, Arc<RecordingBroadcaster>) {
|
|
let bc = Arc::new(RecordingBroadcaster::default());
|
|
let state = TopicsState {
|
|
topics: Arc::new(InMemoryTopicRepo::default()),
|
|
apps: Arc::new(InMemoryAppRepo(app_id)),
|
|
authz,
|
|
broadcaster: bc.clone(),
|
|
};
|
|
(state, bc)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn register_defaults_external_subscribable_false() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let (status, Json(topic)) = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path(app),
|
|
Json(CreateTopicRequest {
|
|
name: "chat".into(),
|
|
external_subscribable: false,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert!(!topic.external_subscribable);
|
|
assert_eq!(topic.name, "chat");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn flip_requires_app_admin_role() {
|
|
let app = AppId::new();
|
|
// Topic exists; the caller has no role → PATCH is forbidden.
|
|
let (s, _) = state(app, Arc::new(DenyAuthzRepo));
|
|
s.topics
|
|
.create(app, "chat", false, TopicAuthMode::Public)
|
|
.await
|
|
.unwrap();
|
|
let err = update_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path((app, "chat".to_string())),
|
|
Json(UpdateTopicRequest {
|
|
external_subscribable: Some(true),
|
|
auth_mode: None,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, TopicsApiError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn flip_is_its_own_endpoint_and_toggles_external() {
|
|
// The PATCH handler is a distinct surface from create; flipping
|
|
// external_subscribable false→true is a single discrete call.
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
s.topics
|
|
.create(app, "chat", false, TopicAuthMode::Public)
|
|
.await
|
|
.unwrap();
|
|
let Json(updated) = update_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path((app, "chat".to_string())),
|
|
Json(UpdateTopicRequest {
|
|
external_subscribable: Some(true),
|
|
auth_mode: Some(TopicAuthMode::Token),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(updated.external_subscribable);
|
|
assert_eq!(updated.auth_mode, TopicAuthMode::Token);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_disconnects_subscribers() {
|
|
let app = AppId::new();
|
|
let (s, bc) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
s.topics
|
|
.create(app, "chat", true, TopicAuthMode::Public)
|
|
.await
|
|
.unwrap();
|
|
let status = delete_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path((app, "chat".to_string())),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
assert_eq!(
|
|
bc.dropped.lock().unwrap().as_slice(),
|
|
&[(app, "chat".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_app_admin_cannot_manage_other_app() {
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
// Caller is admin of app A only; both apps exist via separate state.
|
|
let authz = Arc::new(PerAppAuthzRepo { granted_app: app_a });
|
|
// App-B-scoped state, but the caller only has A's grant.
|
|
let (s, _) = state(app_b, authz);
|
|
let err = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path(app_b),
|
|
Json(CreateTopicRequest {
|
|
name: "chat".into(),
|
|
external_subscribable: true,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, TopicsApiError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pattern_name_rejected() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let err = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path(app),
|
|
Json(CreateTopicRequest {
|
|
name: "user.*".into(),
|
|
external_subscribable: true,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, TopicsApiError::Invalid(_)));
|
|
}
|
|
}
|