Skip to content

feat(channels): default trust-none policy with UID echo for all channel adapters #1262

Description

@chaodu-agent

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

  • Per-platform config sections as first-class citizens
  • Flip default: empty allowed_users + no allow_all_users = true → deny all
  • Unknown sender receives echo with their sender ID
  • Trust check (L3) consolidated into AdapterRouter::handle_message()
  • Remove scattered trust checks from event handlers
  • All messages logged with sender ID + platform
  • Echo uses existing ChatAdapter::send_message()
  • Deprecate [gateway].allowed_users
  • Migration guide

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions