Summary
All OAB channel adapters should default to trust none. No incoming message from any channel should trigger agent actions unless the sender is explicitly whitelisted in config. This applies universally across all supported and planned channels.
Design Principle
Trust no one by default. Echo the sender identity back so they can request access.
Trust Pyramid (Defense in Depth)
Three layers with clearly separated responsibilities — only L1 and L3 are security boundaries. L2 is operator scoping, not authorization.
▲
╱ ╲
╱ ╲
╱ L3 ╲ 🔒 Layer 3: Identity Trust Control (SECURITY)
╱ ╲ allowed_users per platform — default DENY-ALL
╱ sender ╲ "Is THIS IDENTITY allowed?" covers every path incl. DMs
╱ allowed? ╲
╱─────────────╲
╱ ╲
╱ L2 ╲ 🔓 Layer 2: Channel/Group Scope Control (NOT security)
╱ ╲ allowed_channels, allowed_groups, allow_dm — default OPEN
╱ surface open? ╲ "Which CONVERSATION SURFACES does the bot engage in?"
╱ (channel/group/DM) ╲ optional operator scoping (noise/cost), not authorization
╱─────────────────────────╲
╱ ╲
╱ L1 ╲ 🔒 Layer 1: Platform Authentication (SECURITY)
╱ ╲ "Is this request REALLY from the platform?"
╱ webhook signature / JWT / ╲
╱ secret token / IP range ╲
╱─────────────────────────────────────╲
Default posture: L1 always on (edge) · L2 open unless explicitly disabled · L3 deny-all unless explicitly allowed.
Layer 1: Platform Authentication (gateway layer)
Verifies the webhook request is genuinely from the platform, not spoofed. This is the only security check at the gateway level.
| Platform |
Auth Mechanism |
How it works |
| Telegram |
Secret Token + IP Range |
X-Telegram-Bot-Api-Secret-Token header + source IP must be in Telegram subnet (149.154.160.0/20, 91.108.4.0/22) |
| LINE |
HMAC-SHA256 Signature |
X-Line-Signature = HMAC(channel_secret, request_body) |
| Feishu |
SHA256 Signature + Encrypt Key |
SHA256(timestamp + nonce + encrypt_key + body) |
| WeCom |
Token Signature + AES Decrypt |
SHA1(sort(token, timestamp, nonce, encrypt)), AES-256-CBC decryption |
| Google Chat |
JWT (Bearer Token) |
RS256 JWT signed by Google, verify via JWKS + email claim = chat@system.gserviceaccount.com |
| MS Teams |
JWT (OpenID Connect) |
RS256 JWT verified via Bot Framework OpenID metadata + JWKS |
| Slack |
Socket Mode (WS) |
App-Level Token (xapp-...) authenticates the WebSocket connection |
| Discord |
Gateway WS (serenity) |
Bot Token authenticates the WebSocket connection |
Layer 2: Channel/Group Scope Control (core layer) — NOT a security boundary
Controls which conversation surfaces the bot engages in (channels, groups, DMs). This is operator scoping for noise/cost, not authorization — the platform already guarantees the bot only receives from channels it's a member of, so you cannot receive from a channel you were never added to. Default: OPEN (allow_all_channels = true, allow_dm = true).
allow_dm is an L2 surface toggle with a critical asymmetry: a DM has no platform membership gate (anyone can DM a public bot), so when allow_dm = true the only protection on that path is L3.
| Platform |
Mechanism |
Config |
| Discord |
allowed_channels |
Channel snowflake IDs |
| Slack |
allowed_channels |
Channel IDs (C...) |
| Telegram |
allowed_channels (via gateway config) |
Chat IDs |
| Feishu |
allowed_groups |
Group chat IDs |
| Others |
allowed_channels (via gateway config) |
Platform-specific |
Layer 3: Identity Trust Control (core layer) ← THIS ISSUE — the SECURITY gate
Controls who may trigger agent actions. The one authorization boundary at the policy layer; covers every ingress path including DMs (where it is the sole protection). This issue flips the default to deny-all.
Why L2 must stay open for this to work: the echo-UID "request access" reply only fires if an untrusted sender reaches L3. If L2 defaulted closed (e.g. allow_dm = false), a new user would be silently dropped at the scope layer with no onboarding path. L2-open + L3-deny gives the intended self-service flow.
| Platform |
Config |
Default (current) |
Default (proposed) |
| Discord |
[discord].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| Slack |
[slack].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| Telegram |
[telegram].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| LINE |
[line].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| Feishu |
[feishu].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| WeCom |
[wecom].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| Google Chat |
[googlechat].allowed_users |
⚠️ Allow all |
🔒 Deny all |
| MS Teams |
[teams].allowed_users |
⚠️ Allow all |
🔒 Deny all |
Architecture
Trust check (L3) is a router-level gate (middleware). Individual adapters do not implement trust logic — they cannot bypass the gate because all inbound paths converge at AdapterRouter::handle_message().
┌─────────────────────────────────────────────────────────────────────────────┐
│ INBOUND MESSAGE FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Telegram │ │ LINE │ │ Feishu │ │ WeCom / GC │
│ Webhook │ │ Webhook │ │ WebSocket │ │ Webhook │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ openab-gateway — Layer 1: Platform Auth │
│ │
│ ✅ Verify webhook authenticity (signature / JWT / secret token / IP) │
│ ✅ Normalize → GatewayEvent │
│ ✅ Forward ALL authenticated events │
│ ❌ No user filtering (that is Layer 3, handled in core) │
│ │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ openab-core — Layer 2 + 3 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │
│ │ Discord │ │ Slack │ │ GatewayAdapter │ │
│ │ EventHandler│ │ EventHandler│ │ (TG/LINE/etc.) │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └────────────────┼───────────────────┘ │
│ │ │
│ ▼ (all paths converge here) │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ 🔒 AdapterRouter::handle_message() │ │
│ │ │ │
│ │ L2: scope check (optional, default-open; channel/group/DM) │ │
│ │ L3: TrustConfig::is_allowed(platform, sender_id) — default DENY │ │
│ │ │ │
│ │ if denied → log + echo sender ID + RETURN │ │
│ │ if allowed → dispatch to ACP ✅ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ ACP Session │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Echo Reply Flow (untrusted sender)
┌──────────┐ ┌──────────┐ ┌──────────────────────────────────┐
│ User │ msg │ Gateway │ event │ Core │
│ (unknown)│────────▶│ L1: ✅ │────────▶│ L2: ✅ L3: ❌ │
│ │ │(auth OK) │ │ │
│ │ │ │ reply │ → echo sender ID │
│ │◀────────│◀─────────│◀─────────│ → log + drop │
└──────────┘ └──────────┘ └──────────────────────────────────┘
Echo message:
┌─────────────────────────────────────────┐
│ ⚠️ You are not in the trusted list. │
│ Your ID: 123456789 │
│ Please ask the admin to add your UID │
│ to [telegram].allowed_users │
└─────────────────────────────────────────┘
First-Class Per-Platform Config
Every platform is a top-level section. No more [gateway] catch-all.
[discord]
bot_token = "${DISCORD_BOT_TOKEN}"
allowed_users = ["845835116920307722"]
[slack]
bot_token = "${SLACK_BOT_TOKEN}"
allowed_users = ["U01ABCDEFGH"]
[telegram]
bot_token = "${TELEGRAM_BOT_TOKEN}"
secret_token = "${TELEGRAM_SECRET_TOKEN}"
allowed_users = ["123456789"]
[line]
channel_secret = "${LINE_CHANNEL_SECRET}"
channel_access_token = "${LINE_CHANNEL_ACCESS_TOKEN}"
allowed_users = ["U1234567890abcdef0123456789abcdef"]
[feishu]
app_id = "${FEISHU_APP_ID}"
app_secret = "${FEISHU_APP_SECRET}"
allowed_users = ["ou_xxxxxxxxxxxxxxxxxxxx"]
[wecom]
corp_id = "${WECOM_CORP_ID}"
token = "${WECOM_TOKEN}"
allowed_users = ["zhangsan"]
[googlechat]
service_account = "${GOOGLE_CHAT_SA_JSON}"
allowed_users = ["users/123456789"]
[teams]
app_id = "${TEAMS_APP_ID}"
app_secret = "${TEAMS_APP_SECRET}"
allowed_tenants = ["tenant-uuid"]
allowed_users = ["29:1abc..."]
Implementation
TrustConfig (per platform)
pub struct TrustConfig {
pub allow_all_users: bool, // explicit opt-in, defaults to false
pub allowed_users: HashSet<String>,
}
impl TrustConfig {
pub fn is_allowed(&self, sender_id: &str) -> bool {
self.allow_all_users || self.allowed_users.contains(sender_id)
}
}
Single enforcement point
impl AdapterRouter {
async fn handle_message(&self, adapter: &dyn ChatAdapter, msg: InboundMessage) {
let trust = self.platform_trust_configs.get(adapter.platform());
if !trust.is_allowed(&msg.sender_id) {
info!(sender = %msg.sender_id, platform = %adapter.platform(), "untrusted");
let echo = format!(
"⚠️ You are not in the trusted list.\nYour ID: {}\nPlease ask the admin to add you to [{}].allowed_users.",
msg.sender_id, adapter.platform()
);
let _ = adapter.send_message(&msg.channel, &echo).await;
return;
}
// dispatch...
}
}
Per-channel Sender ID Format
| Platform |
Config section |
Sender ID format |
Example |
| Discord |
[discord] |
Snowflake UID |
845835116920307722 |
| Slack |
[slack] |
Workspace User ID |
U01ABCDEFGH |
| Telegram |
[telegram] |
Numeric UID |
123456789 |
| LINE |
[line] |
User ID string |
U1234567890abcdef0123456789abcdef |
| Feishu |
[feishu] |
Open ID |
ou_xxxxxxxxxxxxxxxxxxxx |
| WeCom |
[wecom] |
UserID |
zhangsan |
| Google Chat |
[googlechat] |
User resource name |
users/123456789 |
| MS Teams |
[teams] |
AAD Object ID |
29:1abc... |
Acceptance Criteria
Related
Summary
All OAB channel adapters should default to trust none. No incoming message from any channel should trigger agent actions unless the sender is explicitly whitelisted in config. This applies universally across all supported and planned channels.
Design Principle
Trust Pyramid (Defense in Depth)
Three layers with clearly separated responsibilities — only L1 and L3 are security boundaries. L2 is operator scoping, not authorization.
Default posture: L1 always on (edge) · L2 open unless explicitly disabled · L3 deny-all unless explicitly allowed.
Layer 1: Platform Authentication (gateway layer)
Verifies the webhook request is genuinely from the platform, not spoofed. This is the only security check at the gateway level.
X-Telegram-Bot-Api-Secret-Tokenheader + source IP must be in Telegram subnet (149.154.160.0/20, 91.108.4.0/22)X-Line-Signature= HMAC(channel_secret, request_body)chat@system.gserviceaccount.comLayer 2: Channel/Group Scope Control (core layer) — NOT a security boundary
Controls which conversation surfaces the bot engages in (channels, groups, DMs). This is operator scoping for noise/cost, not authorization — the platform already guarantees the bot only receives from channels it's a member of, so you cannot receive from a channel you were never added to. Default: OPEN (
allow_all_channels = true,allow_dm = true).allow_dmis an L2 surface toggle with a critical asymmetry: a DM has no platform membership gate (anyone can DM a public bot), so whenallow_dm = truethe only protection on that path is L3.allowed_channelsallowed_channelsallowed_channels(via gateway config)allowed_groupsallowed_channels(via gateway config)Layer 3: Identity Trust Control (core layer) ← THIS ISSUE — the SECURITY gate
Controls who may trigger agent actions. The one authorization boundary at the policy layer; covers every ingress path including DMs (where it is the sole protection). This issue flips the default to deny-all.
Why L2 must stay open for this to work: the echo-UID "request access" reply only fires if an untrusted sender reaches L3. If L2 defaulted closed (e.g.
allow_dm = false), a new user would be silently dropped at the scope layer with no onboarding path. L2-open + L3-deny gives the intended self-service flow.[discord].allowed_users[slack].allowed_users[telegram].allowed_users[line].allowed_users[feishu].allowed_users[wecom].allowed_users[googlechat].allowed_users[teams].allowed_usersArchitecture
Trust check (L3) is a router-level gate (middleware). Individual adapters do not implement trust logic — they cannot bypass the gate because all inbound paths converge at
AdapterRouter::handle_message().Echo Reply Flow (untrusted sender)
First-Class Per-Platform Config
Every platform is a top-level section. No more
[gateway]catch-all.Implementation
TrustConfig (per platform)
Single enforcement point
Per-channel Sender ID Format
[discord]845835116920307722[slack]U01ABCDEFGH[telegram]123456789[line]U1234567890abcdef0123456789abcdef[feishu]ou_xxxxxxxxxxxxxxxxxxxx[wecom]zhangsan[googlechat]users/123456789[teams]29:1abc...Acceptance Criteria
allowed_users+ noallow_all_users = true→ deny allAdapterRouter::handle_message()ChatAdapter::send_message()[gateway].allowed_usersRelated