Kanidm group-gated rooms and minimal mesh calling

Rooms can now require a Kanidm group (via the `groups` OIDC claim,
mapped by `oauth2 update-claim-map` server-side) - dev/ops require
`developers`, enforced at every message path (send, history, SSE).

Adds a minimal WebRTC mesh call feature scoped to the lobby room,
signaled over a separate `call.room.*` NATS subject kept out of the
chat archive: public STUN only, no TURN, no SFU - small groups on
friendly networks, by design.
This commit is contained in:
2026-07-27 23:52:27 +02:00
parent 46bdad1629
commit 650ed50c21
12 changed files with 875 additions and 26 deletions
+33 -9
View File
@@ -3,23 +3,42 @@ use serde::{Deserialize, Serialize};
/// Rooms available in the UI. Each maps to the NATS subject
/// `chat.room.<name>`, so any other NATS client on the bus can join in.
pub const ROOMS: &[(&str, &str)] = &[
("lobby", "general traffic"),
("dev", "build & ship"),
("ops", "incidents & infra"),
("random", "off the record"),
/// The third field is the Kanidm group (via the `groups` OIDC claim,
/// see `oauth2 update-claim-map`) required to read/post in that room -
/// `None` means open to anyone in `cnats_users`.
pub const ROOMS: &[(&str, &str, Option<&str>)] = &[
("lobby", "general traffic", None),
("dev", "build & ship", Some("developers")),
("ops", "incidents & infra", Some("developers")),
("random", "off the record", None),
];
pub const DEFAULT_ROOM: &str = "lobby";
pub fn is_valid_room(room: &str) -> bool {
ROOMS.iter().any(|(name, _)| *name == room)
ROOMS.iter().any(|(name, _, _)| *name == room)
}
pub fn room_subject(room: &str) -> String {
format!("chat.room.{room}")
}
/// Whether `user` may read/post in `room`. `false` for an unknown room -
/// callers should check `is_valid_room` separately if they need to tell
/// "unknown room" and "not authorized" apart in the error they return.
/// Synchronous and I/O-free: the user's groups are already baked into
/// their session (from the `groups` OIDC claim at login), so this never
/// needs a live Kanidm round-trip - and never gets more current than
/// that login until they sign in again.
pub fn is_authorized_for_room(user: &crate::auth::User, room: &str) -> bool {
ROOMS
.iter()
.find(|(name, _, _)| *name == room)
.is_some_and(|(_, _, required_group)| {
required_group.is_none_or(|g| user.groups.iter().any(|ug| ug == g))
})
}
/// A single chat message as it travels over NATS (JSON-encoded payload).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
@@ -62,6 +81,9 @@ pub async fn send_message(room: String, text: String) -> Result<(), ServerFnErro
else {
return Err(ServerFnError::new("not signed in"));
};
if !is_authorized_for_room(&user, &room) {
return Err(ServerFnError::new("not authorized for this room"));
}
let state = expect_context::<AppState>();
let now = chrono::Utc::now();
@@ -94,13 +116,15 @@ pub async fn room_history(room: String) -> Result<Vec<ChatMessage>, ServerFnErro
return Err(ServerFnError::new("unknown room"));
}
let session: tower_sessions::Session = leptos_axum::extract().await?;
if session
let Some(user) = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.is_none()
{
else {
return Err(ServerFnError::new("not signed in"));
};
if !is_authorized_for_room(&user, &room) {
return Err(ServerFnError::new("not authorized for this room"));
}
let state = expect_context::<AppState>();