Gating access
Decide who can join a room. The two gates (identity + GroupPolicy), the public-only default, and worked examples for private, membership, tenant, and role-based rooms.
Gating access to rooms
A client subscribes to a room by joining a group at the SSE/WS handshake. Whether that join is allowed is decided by two gates working together:
- Identity resolution - the framework figures out who is connecting (
user_id: Option<&str>). - Group policy - your
GroupPolicydecides whether this user may join this group.
Identity is the user's primary key rendered to a string, so gating is PK-type-agnostic - an i64, String, or uuid::Uuid PK all arrive as the same canonical string ("42", "550e8400-…").
A group the policy rejects fails the whole handshake with 403 Forbidden before any data flows. This is default-deny: a client can never subscribe to a room it has no claim to.
Group names are the security boundary. Anything in a public: group is readable by anyone (the default policy lets anyone join public:*). Only name a group public: when its data is genuinely public. Everything sensitive belongs in a group the policy gates.
The default policy: public:* only
With no configuration, RealtimePlugin uses PublicGroupsOnly. Its rule is exactly one line: allow only groups whose name starts with public:.
// The built-in default - you get this for free:RealtimePlugin::new() // PublicGroupsOnlypublic:lobby,public:posts→ anyone may join, signed in or not.chat:123,tenant:99,user:7→ denied for everyone, including authenticated users.
To grant access to private rooms, override the policy and decide from the authenticated identity.
Gate 1: who is connecting (user_id)
Every policy receives user_id: Option<&str> - the authenticated user's PK string, or None for anonymous. The string is the user's primary key rendered to its canonical form, so it works for any PK type (i64 → "42", uuid → "550e8400-…"). By default the identity resolver always returns None, so without wiring identity your policy can never see a real user.
Two ways to populate it:
Anonymous-by-default footgun. If you write a policy that grants user:{id} rooms but forget with_auth_sessions() / identity_resolver(...), user_id is always None and every private room is silently denied. Wire identity first, then write the policy.
Gate 2: the policy - group_policy_fn
The ergonomic way to gate rooms is a closure: |user_id: Option<&str>, group: &str| -> bool. user_id is the user's PK string (so the same gate works for i64, String, and uuid PKs); return true to allow the join. (Under the hood this wraps your closure in FnGroupPolicy.)
Public rooms - anyone
RealtimePlugin::new() .group_policy_fn(|_user_id, group| group.starts_with("public:"));Per-user private room - user:{id}
Each user gets a room only they can join:
RealtimePlugin::new() .with_auth_sessions() .group_policy_fn(|user_id, group| { if group.starts_with("public:") { return true; // public rooms: anyone } match user_id { Some(uid) => group == format!("user:{uid}"), // your own private room None => false, // anonymous: public only } });Membership / multi-tenant - check the DB at the handshake
For a tenant:{id} or a team:{id} room, gate on a real membership check. group_policy_async_fn takes an async closure, so the ORM query is a plain .await — it runs once, at the SSE/WS handshake. Fail closed (false) on a lookup error:
RealtimePlugin::new() .with_auth_sessions() .group_policy_async_fn(|user_id, group| async move { if group.starts_with("public:") { return true; } let Some(uid) = user_id else { return false }; Membership::objects() .filter(membership::USER_ID.eq(&uid) & membership::GROUP.eq(&group)) .exists() .await .unwrap_or(false) // DB error -> fail closed });The async closure receives owned Option<String> / String (it outlives the handshake's stack). The sync group_policy_fn stays the right tool for shape-only rules like the ones above.
Role-based - staff-only channels
RealtimePlugin::new() .with_auth_sessions() .group_policy_fn(|user_id, group| { if group.starts_with("public:") { return true; } if group.starts_with("staff:") { return matches!(user_id, Some(uid) if is_staff(uid)); } false });Complex policies: a named GroupPolicy type
When a closure isn't enough (you want state, or shared helpers across methods), implement the GroupPolicy trait on your own type and pass it to group_policy. The trait is async (via async_trait), so a method can await the DB directly:
use umbral_realtime::{GroupPolicy, RealtimePlugin, async_trait}; struct AppPolicy { // ... any state you need} #[async_trait]impl GroupPolicy for AppPolicy { async fn can_join(&self, user_id: Option<&str>, group: &str) -> bool { if group.starts_with("public:") { return true; } match user_id { Some(uid) => self.allows(uid, group).await, None => false, } }} RealtimePlugin::new() .with_auth_sessions() .group_policy(AppPolicy { /* ... */ });The default can_join (if you don't override it) is the same group.starts_with("public:") rule as PublicGroupsOnly.
How it all fits
| Step | Where | Result |
|---|---|---|
| Resolve identity | with_auth_sessions() / identity_resolver(...) | user_id: Option<&str> (the PK string) |
| Check the join | group_policy_fn(...) / group_policy_async_fn(...) / group_policy(...) | allow → stream; deny → 403 |
| Per-group dispatch | Realtime::to_group(...).send(...) | reaches only joined connections |
Because every send to a group only reaches connections the policy already admitted, the policy is the single chokepoint for "who can see this room" - including for model subscriptions and presence, which both ride the same group dispatch.
Transport-level security is separate from the policy. GroupPolicy decides who may join a room. On top of that, the WebSocket handshake is guarded against cross-site WebSocket hijacking (CSWSH) - cross-origin WS upgrades are rejected in production by default, because CORS does not cover WebSockets. If you serve a separate frontend origin, allow it with allowed_origins([...]). See the WebSocket Origin guard.