matrix-rtc: separate standalone publication from room creation

This commit is contained in:
heather 2026-11-07 21:55:21 +00:00
commit 2ff4caa788
5 changed files with 321 additions and 1 deletions

View file

@ -0,0 +1,171 @@
diff --git a/integration-tests/tests/get_token.rs b/integration-tests/tests/get_token.rs
index 386f97b..102ff4a 100644
--- a/integration-tests/tests/get_token.rs
+++ b/integration-tests/tests/get_token.rs
@@ -353,13 +353,12 @@ async fn remote_token() {
assert_eq!(response["url"].as_str(), Some(sfu.url()));
let jwt = response["jwt"].as_str().unwrap_or_default();
- // The JWT should grant joining, subscribing and updating the
- // participant's own metadata, but not publishing, since the homeserver
- // is remote.
+ // Remote verified users may publish, but the service must not create a
+ // room for them. Room creation remains gated on the full-access list.
let claims = decode_livekit_jwt(jwt);
assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY));
assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true));
- assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false));
+ assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true));
assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true));
assert_eq!(
claims["video"]["canUpdateOwnMetadata"].as_bool(),
diff --git a/integration-tests/tests/sfu_get.rs b/integration-tests/tests/sfu_get.rs
index 5a08445..2cc8672 100644
--- a/integration-tests/tests/sfu_get.rs
+++ b/integration-tests/tests/sfu_get.rs
@@ -323,7 +323,7 @@ async fn remote_token() {
let claims = decode_livekit_jwt(jwt);
assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY));
assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true));
- assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false));
+ assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true));
assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true));
assert_eq!(
claims["sub"].as_str(),
diff --git a/src/handler.rs b/src/handler.rs
index 2797194..7910e1e 100644
--- a/src/handler.rs
+++ b/src/handler.rs
@@ -978,7 +978,11 @@ impl Handler {
&lk_room_alias,
&lk_identity,
&matrix_id,
- is_full_access_user,
+ // Publication is an authenticated participation permission, not a
+ // room-creation privilege. OpenID and (when configured) membership
+ // checks above must still succeed. Keep creation/delegation gated
+ // below and LiveKit room.auto_create=false on the SFU.
+ true,
)?;
if is_full_access_user {
@@ -1058,7 +1062,11 @@ impl Handler {
&lk_room_alias,
&lk_identity,
&matrix_id,
- is_full_access_user,
+ // Publication is an authenticated participation permission, not a
+ // room-creation privilege. OpenID and (when configured) membership
+ // checks above must still succeed. Keep creation/delegation gated
+ // below and LiveKit room.auto_create=false on the SFU.
+ true,
)?;
if is_full_access_user {
diff --git a/src/handler_tests.rs b/src/handler_tests.rs
index 54d1650..e6c943c 100644
--- a/src/handler_tests.rs
+++ b/src/handler_tests.rs
@@ -5403,3 +5403,87 @@ async fn test_membership_skipped_unless_hs_is_the_only_full_access_homeserver()
handler.close().await;
}
}
+
+// iscg standalone publication policy regression tests. The full-access list
+// governs room creation/delegation, never whether a verified participant speaks.
+#[tokio::test]
+async fn test_iscg_standalone_publication_does_not_grant_creation() {
+ for path in ["/sfu/get", "/get_token"] {
+ for server in ["example.com", "astr.to", "unrelated.example"] {
+ let sub = format!("@user:{server}");
+ let created = Arc::new(AtomicUsize::new(0));
+ let counter = created.clone();
+ let deps = HandlerTestDeps {
+ exchange_openid_userinfo_fn: exchange_openid_userinfo_ok(&sub),
+ create_livekit_room_fn: Some(Box::new(move |_, _, _| {
+ counter.fetch_add(1, Ordering::SeqCst);
+ Ok(())
+ })),
+ ..Default::default()
+ };
+ let handler = new_get_token_handler(deps);
+ let mut req = valid_sfu_request();
+ req.openid_token.matrix_server_name = server.into();
+ req.member.claimed_user_id = sub.clone();
+ let body = if path == "/get_token" {
+ serde_json::to_string(&req).unwrap()
+ } else {
+ serde_json::to_string(&LegacySfuRequest {
+ room: req.room_id.clone(),
+ openid_token: req.openid_token.clone(),
+ device_id: req.member.claimed_device_id.clone(),
+ ..Default::default()
+ }).unwrap()
+ };
+ let resp = send_request(&handler, post_json(path, body)).await;
+ assert_eq!(resp.status(), StatusCode::OK, "{path} {server}");
+ let result: SfuResponse = serde_json::from_slice(&body_bytes(resp).await).unwrap();
+ let claims = parse_jwt_claims(&result.jwt, &handler.livekit_auth.secret);
+ assert_eq!(claims["video"]["canPublish"], true, "{path} {server}");
+ assert_eq!(claims["video"]["canSubscribe"], true);
+ assert_eq!(claims["video"]["roomJoin"], true);
+ for privilege in ["roomCreate", "roomAdmin", "roomList", "roomRecord", "ingressAdmin"] {
+ assert_ne!(claims["video"][privilege], true, "{path}: {privilege}");
+ }
+ let want_room = livekit_room_alias_for(&req.room_id, &req.slot_id);
+ assert_eq!(claims["video"]["room"], want_room.0);
+ assert_eq!(created.load(Ordering::SeqCst), usize::from(server == "example.com"));
+ assert_eq!(handler.is_full_access_user(server), server == "example.com");
+ handler.close().await;
+ }
+ }
+}
+
+#[tokio::test]
+async fn test_iscg_publication_still_requires_valid_openid() {
+ for path in ["/sfu/get", "/get_token"] {
+ for forged_subject in [false, true] {
+ let deps = HandlerTestDeps {
+ exchange_openid_userinfo_fn: if forged_subject {
+ exchange_openid_userinfo_ok("@victim:example.com")
+ } else {
+ Some(Box::new(|_| Err("invalid or expired token".into())))
+ },
+ // An accidental room creation or membership request panics.
+ ..Default::default()
+ };
+ let handler = new_get_token_handler(deps);
+ let mut req = valid_sfu_request();
+ req.openid_token.matrix_server_name = "astr.to".into();
+ req.member.claimed_user_id = "@user:astr.to".into();
+ let body = if path == "/get_token" {
+ serde_json::to_string(&req).unwrap()
+ } else {
+ serde_json::to_string(&LegacySfuRequest {
+ room: req.room_id,
+ openid_token: req.openid_token,
+ device_id: "DEVICE".into(),
+ ..Default::default()
+ }).unwrap()
+ };
+ let resp = send_request(&handler, post_json(path, body)).await;
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} forged={forged_subject}");
+ handler.close().await;
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 2a057d6..7e7ebfd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,6 +49,10 @@ async fn main() {
}
}
+ info!(
+ "iscg standalone policy v1: verified users may publish; room creation and delayed-leave delegation remain full-access only"
+ );
+
let config = match parse_config() {
Ok(config) => config,
Err(err) => {