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 or the session
For a tenant:{id} or a team:{id} room, gate on a membership check. The closure is synchronous, so resolve membership from data you can read synchronously (a cache, or a check that doesn't need .await):
RealtimePlugin::new() .with_auth_sessions() .group_policy_fn(|user_id, group| { if group.starts_with("public:") { return true; } match user_id { Some(uid) => is_member(uid, group), // your membership lookup -> bool None => false, } });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:
use umbral_realtime::{GroupPolicy, RealtimePlugin}; struct AppPolicy { // ... any state you need} impl GroupPolicy for AppPolicy { 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), 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(...) | 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.