From 8401ebf11f5fcff09d0287c21d44a93dd86301df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Tue, 30 Jun 2026 12:54:54 +0000 Subject: [PATCH 1/6] docs(adr): first-class per-platform config & trust-none default Propose promoting all gateway-connected platforms (Telegram, LINE, Feishu, WeCom, Google Chat, MS Teams) to top-level config sections, matching the existing [discord] and [slack] structure. Key decisions: - Per-platform [telegram], [line], [feishu], etc. sections - Trust-none default (empty allowed_users = deny all) - Single trust gate at AdapterRouter::handle_message() - Echo sender ID on deny - Deprecate [gateway] catch-all section Tracking: #1262 --- docs/adr/first-class-platform-config.md | 299 ++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/adr/first-class-platform-config.md diff --git a/docs/adr/first-class-platform-config.md b/docs/adr/first-class-platform-config.md new file mode 100644 index 000000000..ffeb63343 --- /dev/null +++ b/docs/adr/first-class-platform-config.md @@ -0,0 +1,299 @@ +# ADR: First-Class Per-Platform Configuration & Trust-None Default + +- **Status:** Proposed +- **Date:** 2026-06-30 +- **Author:** @chaodu-agent +- **Reviewers:** @pahud +- **Tracking issues:** #1262 + +--- + +## 1. Context & Decision + +Promote all gateway-connected platforms (Telegram, LINE, Feishu, WeCom, Google Chat, MS Teams) to **first-class citizens** in `config.toml`, each with their own top-level section — identical in structure to the existing `[discord]` and `[slack]` sections. + +Additionally, flip the default trust model from **allow-all** to **trust-none**: when `allowed_users` is empty and `allow_all_users` is not explicitly set to `true`, deny all incoming messages. + +## 2. Motivation + +### Problem 1: Gateway platforms are second-class + +Currently, all gateway-connected platforms share a single `[gateway]` config section: + +```toml +# ❌ Current: one catch-all for ALL gateway platforms +[gateway] +url = "ws://openab-gateway:8080/ws" +platform = "telegram" # only identifies which gateway to connect to +allowed_users = ["123456789"] # shared list for ALL platforms behind this gateway +``` + +This is fundamentally broken: +- **ID format mixing** — Telegram UIDs (`123456789`) and LINE User IDs (`U1234abc...`) in the same list +- **No per-platform trust** — trusting a Telegram user implicitly trusts that same string on LINE +- **Asymmetry** — Discord and Slack get rich per-platform config; everything else is a second-class `[gateway]` blob +- **Multi-gateway deployments** — running Telegram + LINE requires multiple `[gateway]` sections with unclear semantics + +### Problem 2: Trust-all default is insecure + +All adapters auto-detect: empty `allowed_users` → `allow_all_users = true`. This means a fresh deployment with no user configuration trusts **everyone** by default. + +## 3. Decision + +### 3.1 Per-platform top-level config sections + +Every platform gets its own section with platform-specific settings + unified trust fields: + +```toml +[discord] +bot_token = "${DISCORD_BOT_TOKEN}" +allowed_users = ["845835116920307722"] +# allow_all_users = true # opt-in to trust-all + +[slack] +bot_token = "${SLACK_BOT_TOKEN}" +app_token = "${SLACK_APP_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"] +allowed_groups = ["oc_xxxxx"] + +[wecom] +corp_id = "${WECOM_CORP_ID}" +agent_id = "${WECOM_AGENT_ID}" +allowed_users = ["zhangsan"] + +[googlechat] +service_account = "${GOOGLE_CHAT_SA_JSON}" +allowed_users = ["users/123456789"] + +[teams] +app_id = "${TEAMS_APP_ID}" +allowed_tenants = ["tenant-uuid"] +allowed_users = ["29:1abc..."] +``` + +### 3.2 Trust-none default + +``` +Current: empty allowed_users → allow_all_users = true (TRUST ALL) +Proposed: empty allowed_users → allow_all_users = false (TRUST NONE) +``` + +When a message arrives from an untrusted sender, the system: +1. Logs the event (sender ID, platform, timestamp) +2. Replies with an echo message showing the sender their own ID +3. Does NOT dispatch to any agent + +### 3.3 Trust check at router level (single gate) + +Trust enforcement happens in **one place only**: `AdapterRouter::handle_message()`. The gateway remains a pure transport layer. + +``` +Gateway (transport): webhook → verify authenticity → normalize → forward +Core (policy): AdapterRouter → TrustConfig::is_allowed(platform, sender_id) → echo or dispatch +``` + +--- + +## 4. Architecture + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Telegram │ │ LINE │ │ Feishu │ │ WeCom / GC │ +│ Webhook │ │ Webhook │ │ WebSocket │ │ Webhook │ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ openab-gateway (transport only) │ +│ │ +│ ✅ Verify webhook signature / secret token / IP │ +│ ✅ Normalize → GatewayEvent │ +│ ✅ Forward ALL events │ +│ ❌ No trust check, no user filtering │ +└────────────────────────────┬────────────────────────────────────────┘ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ openab-core │ +│ │ +│ ┌───────────┐ ┌───────────┐ ┌─────────────────────────────┐ │ +│ │ Discord │ │ Slack │ │ GatewayAdapter │ │ +│ │ Handler │ │ Handler │ │ (TG/LINE/Feishu/WeCom/GC) │ │ +│ └─────┬─────┘ └─────┬─────┘ └──────────────┬──────────────┘ │ +│ │ │ │ │ +│ └──────────────┼──────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 🔒 AdapterRouter::handle_message() │ │ +│ │ │ │ +│ │ trust = platform_trust_configs.get(adapter.platform()) │ │ +│ │ if !trust.is_allowed(sender_id): │ │ +│ │ log + echo sender ID + RETURN │ │ +│ │ else: │ │ +│ │ dispatch to ACP ✅ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ ACP Session │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Per-platform TrustConfig + +```rust +pub struct TrustConfig { + pub allow_all_users: bool, // explicit opt-in, defaults to false + pub allowed_users: HashSet, +} + +impl TrustConfig { + pub fn is_allowed(&self, sender_id: &str) -> bool { + self.allow_all_users || self.allowed_users.contains(sender_id) + } +} + +/// Router holds one TrustConfig per platform +pub struct PlatformTrustConfigs { + configs: HashMap, // keyed by platform name +} + +impl PlatformTrustConfigs { + pub fn get(&self, platform: &str) -> &TrustConfig { + self.configs.get(platform).unwrap_or(&DEFAULT_DENY) + } +} + +static DEFAULT_DENY: TrustConfig = TrustConfig { + allow_all_users: false, + allowed_users: HashSet::new(), // empty = deny all +}; +``` + +### Echo reply on deny + +```rust +// In AdapterRouter::handle_message() +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; +``` + +--- + +## 5. Migration + +### Breaking change + +Existing deployments with no `allowed_users` configured will stop accepting messages after this change. + +### Migration path + +Add `allow_all_users = true` to maintain old behavior: + +```toml +# Before (implicit trust-all): +[discord] +bot_token = "..." + +# After (explicit trust-all): +[discord] +bot_token = "..." +allow_all_users = true +``` + +### `[gateway]` deprecation + +The `[gateway]` section remains functional for backward compatibility but is deprecated. Users should migrate to per-platform sections: + +```toml +# ❌ Deprecated +[gateway] +platform = "telegram" +allowed_users = ["123"] + +# ✅ Migrate to +[telegram] +allowed_users = ["123"] +``` + +--- + +## 6. Sender ID Formats + +| Platform | Config section | 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...` | + +--- + +## 7. Implementation Plan + +1. **Define `TrustConfig` struct** and `PlatformTrustConfigs` in `openab-core` +2. **Add per-platform config parsing** — each `[platform]` section reads `allowed_users` and `allow_all_users` +3. **Wire trust gate into `AdapterRouter::handle_message()`** — single check point +4. **Remove scattered trust checks** from: + - `is_denied_user()` in Discord EventHandler + - `should_skip_event()` user filter in `gateway.rs` + - `allowed_users` check in Feishu gateway adapter +5. **Add echo reply** on deny using `ChatAdapter::send_message()` +6. **Deprecation warning** for `[gateway].allowed_users` — log warning if old config detected +7. **Update `config.toml.example`** and docs +8. **Migration guide** in release notes + +--- + +## 8. Rejected Alternatives + +### Per-adapter `InboundGate` trait + +Each adapter implements `is_trusted_sender()`. Rejected because: +- Trust logic is identical across all platforms (`allowed_users.contains(id)`) +- Forces N identical implementations with no polymorphic benefit +- New adapter forgetting to implement = security hole +- Router-level gate is impossible to bypass by construction + +### Trust check at gateway layer + +Gateway adapters filter untrusted senders before forwarding. Rejected because: +- Gateway is transport — mixing business logic violates separation of concerns +- Trust config lives in core's `config.toml`, not gateway env vars +- Would split config into two places (env vars + toml) +- Reply capability is already wired in core via `ChatAdapter::send_message()` + +### Keep `[gateway]` with per-platform sub-sections + +```toml +[gateway.telegram] +allowed_users = [...] +[gateway.line] +allowed_users = [...] +``` + +Rejected because it still treats gateway platforms as subordinate. A `[telegram]` section is more intuitive and symmetric with `[discord]` / `[slack]`. From f5ac0067d33963f2ccac4efcc3dddaa3ba35ade5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Tue, 30 Jun 2026 13:12:03 +0000 Subject: [PATCH 2/6] docs(adr): add trust pyramid with platform auth comparison table Layer 1 (gateway): Platform authentication mechanisms per adapter - Telegram: secret token + IP range - LINE: HMAC-SHA256 - Feishu: SHA256 signature + encrypt key - WeCom: token signature + AES decrypt - Google Chat: JWT (RS256 via JWKS) - MS Teams: JWT (OpenID Connect) - Slack/Discord: WebSocket token auth Layer 2 (core): Channel/group trust (existing) Layer 3 (core): User trust (this ADR - flip to deny-all) --- docs/adr/first-class-platform-config.md | 137 ++++++++++++++++-------- 1 file changed, 90 insertions(+), 47 deletions(-) diff --git a/docs/adr/first-class-platform-config.md b/docs/adr/first-class-platform-config.md index ffeb63343..fa2bb077e 100644 --- a/docs/adr/first-class-platform-config.md +++ b/docs/adr/first-class-platform-config.md @@ -24,23 +24,75 @@ Currently, all gateway-connected platforms share a single `[gateway]` config sec # ❌ Current: one catch-all for ALL gateway platforms [gateway] url = "ws://openab-gateway:8080/ws" -platform = "telegram" # only identifies which gateway to connect to -allowed_users = ["123456789"] # shared list for ALL platforms behind this gateway +platform = "telegram" +allowed_users = ["123456789"] # shared list — what platform is this ID for? ``` This is fundamentally broken: - **ID format mixing** — Telegram UIDs (`123456789`) and LINE User IDs (`U1234abc...`) in the same list - **No per-platform trust** — trusting a Telegram user implicitly trusts that same string on LINE -- **Asymmetry** — Discord and Slack get rich per-platform config; everything else is a second-class `[gateway]` blob -- **Multi-gateway deployments** — running Telegram + LINE requires multiple `[gateway]` sections with unclear semantics +- **Asymmetry** — Discord and Slack get rich per-platform config; everything else is second-class +- **Multi-gateway deployments** — running Telegram + LINE requires unclear semantics ### Problem 2: Trust-all default is insecure -All adapters auto-detect: empty `allowed_users` → `allow_all_users = true`. This means a fresh deployment with no user configuration trusts **everyone** by default. +All adapters auto-detect: empty `allowed_users` → `allow_all_users = true`. A fresh deployment trusts **everyone** by default. + +## 3. Trust Pyramid (Defense in Depth) + +Three layers of security, from broadest (platform) to narrowest (user): + +``` + ▲ + ╱ ╲ + ╱ ╲ + ╱ L3 ╲ 🔒 Layer 3: User Trust + ╱ ╲ allowed_users per platform + ╱ sender ╲ "Is THIS PERSON allowed?" + ╱ allowed? ╲ + ╱─────────────╲ + ╱ ╲ + ╱ L2 ╲ 🔒 Layer 2: Channel/Group Trust + ╱ ╲ allowed_channels, allowed_groups + ╱ channel/group ╲ "Is this CONVERSATION allowed?" + ╱ allowed? ╲ + ╱─────────────────────────╲ + ╱ ╲ + ╱ L1 ╲ 🔒 Layer 1: Platform Authentication + ╱ ╲ "Is this request REALLY from the platform?" + ╱ webhook signature / JWT / ╲ + ╱ secret token / IP range ╲ + ╱─────────────────────────────────────╲ +``` + +### Layer 1: Platform Authentication (gateway layer — transport) + +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 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 body decryption | +| **Google Chat** | JWT (RS256) | Bearer token verified via Google JWKS; email claim = `chat@system.gserviceaccount.com` | +| **MS Teams** | JWT (OpenID Connect) | RS256 JWT verified via Bot Framework OpenID metadata + JWKS | +| **Slack** | Socket Mode WebSocket | App-Level Token (xapp-...) authenticates WS connection | +| **Discord** | Gateway WebSocket | Bot Token authenticates WS connection | + +### Layer 2: Channel/Group Trust (core layer) + +Controls which conversations the bot participates in. Already implemented. -## 3. Decision +### Layer 3: User Trust (core layer) ← This ADR -### 3.1 Per-platform top-level config sections +Controls which individual senders can trigger agent actions. Currently defaults to allow-all. This ADR proposes flipping to deny-all. + +--- + +## 4. Decision + +### 4.1 Per-platform top-level config sections Every platform gets its own section with platform-specific settings + unified trust fields: @@ -73,7 +125,7 @@ allowed_groups = ["oc_xxxxx"] [wecom] corp_id = "${WECOM_CORP_ID}" -agent_id = "${WECOM_AGENT_ID}" +token = "${WECOM_TOKEN}" allowed_users = ["zhangsan"] [googlechat] @@ -82,34 +134,30 @@ allowed_users = ["users/123456789"] [teams] app_id = "${TEAMS_APP_ID}" +app_secret = "${TEAMS_APP_SECRET}" allowed_tenants = ["tenant-uuid"] allowed_users = ["29:1abc..."] ``` -### 3.2 Trust-none default +### 4.2 Trust-none default ``` Current: empty allowed_users → allow_all_users = true (TRUST ALL) Proposed: empty allowed_users → allow_all_users = false (TRUST NONE) ``` -When a message arrives from an untrusted sender, the system: -1. Logs the event (sender ID, platform, timestamp) -2. Replies with an echo message showing the sender their own ID -3. Does NOT dispatch to any agent - -### 3.3 Trust check at router level (single gate) +When a message arrives from an untrusted sender: +1. Log the event (sender ID, platform, timestamp) +2. Reply with an echo message showing the sender their own ID +3. Do NOT dispatch to any agent -Trust enforcement happens in **one place only**: `AdapterRouter::handle_message()`. The gateway remains a pure transport layer. +### 4.3 Trust check at router level (single gate) -``` -Gateway (transport): webhook → verify authenticity → normalize → forward -Core (policy): AdapterRouter → TrustConfig::is_allowed(platform, sender_id) → echo or dispatch -``` +Trust enforcement happens in **one place only**: `AdapterRouter::handle_message()`. The gateway remains a pure transport layer (L1 only). --- -## 4. Architecture +## 5. Architecture ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ @@ -119,33 +167,32 @@ Core (policy): AdapterRouter → TrustConfig::is_allowed(platform, sender │ │ │ │ ▼ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────────────┐ -│ openab-gateway (transport only) │ +│ openab-gateway — L1: Platform Authentication │ │ │ -│ ✅ Verify webhook signature / secret token / IP │ +│ ✅ Verify webhook signature / JWT / secret token / IP │ │ ✅ Normalize → GatewayEvent │ -│ ✅ Forward ALL events │ -│ ❌ No trust check, no user filtering │ +│ ✅ Forward ALL authenticated events │ +│ ❌ No user filtering (L3 is in core) │ └────────────────────────────┬────────────────────────────────────────┘ │ WebSocket ▼ ┌─────────────────────────────────────────────────────────────────────┐ -│ openab-core │ +│ openab-core — L2 + L3 │ │ │ │ ┌───────────┐ ┌───────────┐ ┌─────────────────────────────┐ │ │ │ Discord │ │ Slack │ │ GatewayAdapter │ │ │ │ Handler │ │ Handler │ │ (TG/LINE/Feishu/WeCom/GC) │ │ │ └─────┬─────┘ └─────┬─────┘ └──────────────┬──────────────┘ │ -│ │ │ │ │ │ └──────────────┼──────────────────────┘ │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────┐ │ │ │ 🔒 AdapterRouter::handle_message() │ │ │ │ │ │ -│ │ trust = platform_trust_configs.get(adapter.platform()) │ │ -│ │ if !trust.is_allowed(sender_id): │ │ -│ │ log + echo sender ID + RETURN │ │ -│ │ else: │ │ -│ │ dispatch to ACP ✅ │ │ +│ │ L2: channel/group check (existing) │ │ +│ │ L3: TrustConfig::is_allowed(platform, sender_id) │ │ +│ │ │ │ +│ │ if denied → log + echo sender ID → RETURN │ │ +│ │ if allowed → dispatch to ACP ✅ │ │ │ └───────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ @@ -200,22 +247,20 @@ let _ = adapter.send_message(&msg.channel, &echo).await; --- -## 5. Migration +## 6. Migration ### Breaking change -Existing deployments with no `allowed_users` configured will stop accepting messages after this change. +Existing deployments with no `allowed_users` configured will stop accepting messages. ### Migration path -Add `allow_all_users = true` to maintain old behavior: - ```toml # Before (implicit trust-all): [discord] bot_token = "..." -# After (explicit trust-all): +# After (explicit trust-all to keep old behavior): [discord] bot_token = "..." allow_all_users = true @@ -223,7 +268,7 @@ allow_all_users = true ### `[gateway]` deprecation -The `[gateway]` section remains functional for backward compatibility but is deprecated. Users should migrate to per-platform sections: +The `[gateway]` section remains functional for backward compatibility but is deprecated: ```toml # ❌ Deprecated @@ -238,7 +283,7 @@ allowed_users = ["123"] --- -## 6. Sender ID Formats +## 7. Sender ID Formats | Platform | Config section | ID format | Example | |----------|---------------|-----------|---------| @@ -253,7 +298,7 @@ allowed_users = ["123"] --- -## 7. Implementation Plan +## 8. Implementation Plan 1. **Define `TrustConfig` struct** and `PlatformTrustConfigs` in `openab-core` 2. **Add per-platform config parsing** — each `[platform]` section reads `allowed_users` and `allow_all_users` @@ -263,13 +308,13 @@ allowed_users = ["123"] - `should_skip_event()` user filter in `gateway.rs` - `allowed_users` check in Feishu gateway adapter 5. **Add echo reply** on deny using `ChatAdapter::send_message()` -6. **Deprecation warning** for `[gateway].allowed_users` — log warning if old config detected +6. **Deprecation warning** for `[gateway].allowed_users` 7. **Update `config.toml.example`** and docs 8. **Migration guide** in release notes --- -## 8. Rejected Alternatives +## 9. Rejected Alternatives ### Per-adapter `InboundGate` trait @@ -282,18 +327,16 @@ Each adapter implements `is_trusted_sender()`. Rejected because: ### Trust check at gateway layer Gateway adapters filter untrusted senders before forwarding. Rejected because: -- Gateway is transport — mixing business logic violates separation of concerns +- Gateway is transport (L1) — mixing L3 policy violates layer separation - Trust config lives in core's `config.toml`, not gateway env vars -- Would split config into two places (env vars + toml) -- Reply capability is already wired in core via `ChatAdapter::send_message()` +- Would split config into two places +- Reply capability already wired in core via `ChatAdapter::send_message()` ### Keep `[gateway]` with per-platform sub-sections ```toml [gateway.telegram] allowed_users = [...] -[gateway.line] -allowed_users = [...] ``` Rejected because it still treats gateway platforms as subordinate. A `[telegram]` section is more intuitive and symmetric with `[discord]` / `[slack]`. From f7a10dc56a7c7b635d1938d72cd9d3a5a6bf6e8b Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 16:28:00 -0400 Subject: [PATCH 3/6] =?UTF-8?q?docs(adr):=20refine=20trust=20pyramid=20?= =?UTF-8?q?=E2=80=94=20L2=20scope=20(open=20default)=20vs=20L3=20identity?= =?UTF-8?q?=20(deny=20default)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify the three layers per review discussion: - L1 platform auth (security, edge) - L2 channel/group/DM scope control — NOT security, default OPEN; the platform already enforces channel membership, so L2 is operator scoping - L3 identity trust — THE security gate, default DENY-ALL, covers all paths - allow_dm is an L2 surface toggle; DMs have no platform membership gate so L3 is their sole protection - L2 must stay open by default for the echo-UID request-access flow to work --- docs/adr/first-class-platform-config.md | 68 ++++++++++++++++++++----- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/docs/adr/first-class-platform-config.md b/docs/adr/first-class-platform-config.md index fa2bb077e..5564734ba 100644 --- a/docs/adr/first-class-platform-config.md +++ b/docs/adr/first-class-platform-config.md @@ -40,31 +40,34 @@ All adapters auto-detect: empty `allowed_users` → `allow_all_users = true`. A ## 3. Trust Pyramid (Defense in Depth) -Three layers of security, from broadest (platform) to narrowest (user): +Three layers with **clearly separated responsibilities** — only L1 and L3 are +security boundaries. L2 is operator scoping, not authorization. ``` ▲ ╱ ╲ ╱ ╲ - ╱ L3 ╲ 🔒 Layer 3: User Trust - ╱ ╲ allowed_users per platform - ╱ sender ╲ "Is THIS PERSON allowed?" + ╱ 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 Trust - ╱ ╲ allowed_channels, allowed_groups - ╱ channel/group ╲ "Is this CONVERSATION allowed?" - ╱ 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 + ╱ 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 — transport) Verifies the webhook request is genuinely from the platform, not spoofed. This is the **only** security check at the gateway level. @@ -80,13 +83,52 @@ Verifies the webhook request is genuinely from the platform, not spoofed. This i | **Slack** | Socket Mode WebSocket | App-Level Token (xapp-...) authenticates WS connection | | **Discord** | Gateway WebSocket | Bot Token authenticates WS connection | -### Layer 2: Channel/Group Trust (core layer) +### Layer 2: Channel/Group Scope Control (core layer) — NOT a security boundary + +Controls **which conversation surfaces** the bot engages in — channels, groups, +and DMs (`allow_dm`). Already implemented. + +This is **operator scoping, not authorization**. The platform itself already +guarantees the bot only receives events from channels/groups it is a member of +with read permission — you cannot receive a message from a channel you were never +added to. So `allowed_channels` does not defend against "unauthorized channels" +(L1/the platform already does); it only narrows an over-permissioned bot to the +surfaces an operator wants it active in. Its value is noise/cost control. + +**Default: OPEN** (`allow_all_channels = true`, `allow_dm = true`). Operators +*disable* surfaces only for hard scoping (e.g. a group-only bot sets +`allow_dm = false`). + +**DMs are an L2 surface with a critical asymmetry:** unlike groups, a DM has **no +platform membership gate** — anyone can open a DM with a public bot. So when +`allow_dm = true`, the **only** protection on that path is L3. Enabling the DM +surface is an L2 decision; guarding who may use it is L3. -Controls which conversations the bot participates in. Already implemented. +### Layer 3: Identity Trust Control (core layer) ← This ADR — the SECURITY gate -### Layer 3: User Trust (core layer) ← This ADR +Controls which individual senders can trigger agent actions. Currently defaults +to allow-all; this ADR flips it to **deny-all**. This is the one authorization +boundary at the policy layer, and it covers **every** ingress path — including +DMs, where it is the sole protection. + +**Why L2 must stay open for the deny UX to work:** the "echo your UID so you can +request access" reply only fires if an untrusted sender's message actually +*reaches* L3. If L2 defaulted closed (e.g. `allow_dm = false`), a new user would +be silently dropped at the scope layer with no path to onboard. L2-open + L3-deny +gives the intended self-service flow: + +``` +stranger messages the bot + → L1 ✅ authentic platform request + → L2 ✅ surface open by default (channel / DM) + → L3 ❌ identity not in allowed_users + → echo "⚠️ You're not trusted. Your ID: 123456789. Ask the admin to add you." + → drop — no agent action +``` -Controls which individual senders can trigger agent actions. Currently defaults to allow-all. This ADR proposes flipping to deny-all. +This flips **only L3** from today's allow-all to deny-all; L2 stays open. Minimal +breaking surface, maximal safety: nothing acts for an untrusted identity, yet +strangers still get a way to request access. --- From 0b3a3d8602a6dc7128e6519ab135e9b1f40880e3 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 16:43:41 -0400 Subject: [PATCH 4/6] =?UTF-8?q?docs(adr):=20specify=20trait=20&=20type=20c?= =?UTF-8?q?hanges=20=E2=80=94=20extend=20carriers,=20no=20new=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend TrustConfig with L2 scope fields (allow_all_channels, allow_dm) + surface_allowed(); defaults L2-open / L3-deny - Add 'Trait & Type Changes' section: pass SenderContext in MessageContext, add is_dm to ChannelRef, no new ChatAdapter method/trait (uniform logic) - Note the real refactor = remove scattered trust checks from discord.rs/slack.rs/gateway.rs so the router gate is un-bypassable - Fix architecture diagram gate labels (L2 optional/open, L3 deny default) --- docs/adr/first-class-platform-config.md | 82 ++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/adr/first-class-platform-config.md b/docs/adr/first-class-platform-config.md index 5564734ba..9eb6681d4 100644 --- a/docs/adr/first-class-platform-config.md +++ b/docs/adr/first-class-platform-config.md @@ -230,8 +230,8 @@ Trust enforcement happens in **one place only**: `AdapterRouter::handle_message( │ ┌───────────────────────────────────────────────────────────────┐ │ │ │ 🔒 AdapterRouter::handle_message() │ │ │ │ │ │ -│ │ L2: channel/group check (existing) │ │ -│ │ L3: TrustConfig::is_allowed(platform, sender_id) │ │ +│ │ L2: scope check (optional, default-open; channel/group/DM) │ │ +│ │ L3: TrustConfig::is_allowed(platform, sender_id) — DENY dflt │ │ │ │ │ │ │ │ if denied → log + echo sender ID → RETURN │ │ │ │ if allowed → dispatch to ACP ✅ │ │ @@ -248,11 +248,26 @@ Trust enforcement happens in **one place only**: `AdapterRouter::handle_message( ```rust pub struct TrustConfig { - pub allow_all_users: bool, // explicit opt-in, defaults to false + // L2 — scope control (NOT security). Defaults OPEN. + pub allow_all_channels: bool, // default true + pub allowed_channels: HashSet, + pub allow_dm: bool, // default true (DM surface open) + + // L3 — identity trust (THE security gate). Defaults DENY-ALL. + pub allow_all_users: bool, // explicit opt-in, default false pub allowed_users: HashSet, } impl TrustConfig { + /// L2: is this conversation surface in scope? (default-open) + pub fn surface_allowed(&self, channel_id: &str, is_dm: bool) -> bool { + if is_dm { + return self.allow_dm; + } + self.allow_all_channels || self.allowed_channels.contains(channel_id) + } + + /// L3: is this identity trusted? (default-deny) pub fn is_allowed(&self, sender_id: &str) -> bool { self.allow_all_users || self.allowed_users.contains(sender_id) } @@ -265,16 +280,69 @@ pub struct PlatformTrustConfigs { impl PlatformTrustConfigs { pub fn get(&self, platform: &str) -> &TrustConfig { - self.configs.get(platform).unwrap_or(&DEFAULT_DENY) + self.configs.get(platform).unwrap_or(&DEFAULT) } } -static DEFAULT_DENY: TrustConfig = TrustConfig { - allow_all_users: false, - allowed_users: HashSet::new(), // empty = deny all +/// Default: L2 open (act anywhere the platform allows), L3 deny-all. +static DEFAULT: TrustConfig = TrustConfig { + allow_all_channels: true, + allowed_channels: HashSet::new(), + allow_dm: true, + allow_all_users: false, // trust-none on identity + allowed_users: HashSet::new(), }; ``` +### Trait & Type Changes (no new trait) + +The trust gate is **uniform logic**, not per-platform behavior, so it is a plain +`TrustConfig` + a router method — **not** a `ChatAdapter` method and **not** a new +trait (see Rejected Alternatives). The `ChatAdapter` trait is unchanged: +`platform()` already keys the `TrustConfig` and `send_message()` already performs +the echo. What changes are the **shared data carriers** that feed the router: + +**1. `MessageContext` — carry structured sender identity (not opaque JSON).** +Today the router only receives `sender_json` (a serialized blob); it would have to +parse JSON to read `sender_id`. Pass the `SenderContext` struct so L3 can read +`sender_id` / `is_bot` directly (the router can still serialize it for the agent): + +```rust +pub struct MessageContext { + pub thread_channel: ChannelRef, + pub sender: SenderContext, // ← was: sender_json: String + pub prompt: String, + pub extra_blocks: Vec, + pub trigger_msg: MessageRef, + pub other_bot_present: bool, +} +``` + +**2. `ChannelRef` — add an `is_dm` flag.** +DM detection is platform-specific *structural* knowledge the adapter already has at +construction time (Discord DM channel vs Telegram private chat vs Slack IM), so it +is a **field the adapter populates**, not a trait method. This lets the router +evaluate `allow_dm` (L2) uniformly: + +```rust +pub struct ChannelRef { + pub platform: String, + pub channel_id: String, + pub is_dm: bool, // ← new; excluded from Hash/Eq like origin_event_id + pub thread_id: Option, + pub parent_id: Option, + pub origin_event_id: Option, +} +``` + +**3. Remove scattered trust checks from adapters.** +The real refactor is deleting the `allowed_channels` / `allowed_users` checks +currently in `discord.rs`, `slack.rs`, and `gateway.rs`, and letting the data flow +into `MessageContext` / `ChannelRef` so the single router gate is the only place +trust is enforced — this is what makes L3 un-bypassable. Structural concerns +(thread detection, @mention gating, multibot detection, bot-ownership) **stay in +the adapters** — they are not trust. + ### Echo reply on deny ```rust From ba13dcf34046c55c1be2c3ac97f1d820a865ea8c Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 16:46:33 -0400 Subject: [PATCH 5/6] docs(adr): sharpen title to 'identity trust-none default' --- docs/adr/first-class-platform-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/first-class-platform-config.md b/docs/adr/first-class-platform-config.md index 9eb6681d4..76244b544 100644 --- a/docs/adr/first-class-platform-config.md +++ b/docs/adr/first-class-platform-config.md @@ -1,4 +1,4 @@ -# ADR: First-Class Per-Platform Configuration & Trust-None Default +# ADR: First-Class Per-Platform Configuration & Identity Trust-None Default - **Status:** Proposed - **Date:** 2026-06-30 From f8be8f8e7ea720b385ff9edee4a0548eaeeb78a2 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 16:50:53 -0400 Subject: [PATCH 6/6] docs(adr): scope down to per-platform config only Split the trust/security decision out into a separate ADR (docs/adr/identity-trust-none.md, PR #1264). This ADR now covers only the config schema change: first-class [platform] sections + [gateway] deprecation + migration. --- docs/adr/first-class-platform-config.md | 373 +++--------------------- 1 file changed, 34 insertions(+), 339 deletions(-) diff --git a/docs/adr/first-class-platform-config.md b/docs/adr/first-class-platform-config.md index 76244b544..89b7b15c5 100644 --- a/docs/adr/first-class-platform-config.md +++ b/docs/adr/first-class-platform-config.md @@ -1,22 +1,19 @@ -# ADR: First-Class Per-Platform Configuration & Identity Trust-None Default +# ADR: First-Class Per-Platform Configuration - **Status:** Proposed - **Date:** 2026-06-30 - **Author:** @chaodu-agent - **Reviewers:** @pahud - **Tracking issues:** #1262 +- **Related:** [Identity Trust-None Default & Trust Pyramid](identity-trust-none.md) — builds on the per-platform sections defined here to hold each platform's `allowed_users`. --- ## 1. Context & Decision -Promote all gateway-connected platforms (Telegram, LINE, Feishu, WeCom, Google Chat, MS Teams) to **first-class citizens** in `config.toml`, each with their own top-level section — identical in structure to the existing `[discord]` and `[slack]` sections. +Promote all gateway-connected platforms (Telegram, LINE, Feishu, WeCom, Google Chat, MS Teams) to **first-class citizens** in `config.toml`, each with their own top-level section — identical in structure to the existing `[discord]` and `[slack]` sections — and deprecate the single `[gateway]` catch-all. -Additionally, flip the default trust model from **allow-all** to **trust-none**: when `allowed_users` is empty and `allow_all_users` is not explicitly set to `true`, deny all incoming messages. - -## 2. Motivation - -### Problem 1: Gateway platforms are second-class +## 2. Motivation: gateway platforms are second-class Currently, all gateway-connected platforms share a single `[gateway]` config section: @@ -30,113 +27,15 @@ allowed_users = ["123456789"] # shared list — what platform is this ID for? This is fundamentally broken: - **ID format mixing** — Telegram UIDs (`123456789`) and LINE User IDs (`U1234abc...`) in the same list -- **No per-platform trust** — trusting a Telegram user implicitly trusts that same string on LINE +- **No per-platform scoping** — a value configured for a Telegram user implicitly applies to that same string on LINE - **Asymmetry** — Discord and Slack get rich per-platform config; everything else is second-class -- **Multi-gateway deployments** — running Telegram + LINE requires unclear semantics - -### Problem 2: Trust-all default is insecure - -All adapters auto-detect: empty `allowed_users` → `allow_all_users = true`. A fresh deployment trusts **everyone** by default. - -## 3. 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 — transport) - -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 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 body decryption | -| **Google Chat** | JWT (RS256) | Bearer token verified via Google JWKS; email claim = `chat@system.gserviceaccount.com` | -| **MS Teams** | JWT (OpenID Connect) | RS256 JWT verified via Bot Framework OpenID metadata + JWKS | -| **Slack** | Socket Mode WebSocket | App-Level Token (xapp-...) authenticates WS connection | -| **Discord** | Gateway WebSocket | Bot Token authenticates WS connection | - -### Layer 2: Channel/Group Scope Control (core layer) — NOT a security boundary - -Controls **which conversation surfaces** the bot engages in — channels, groups, -and DMs (`allow_dm`). Already implemented. - -This is **operator scoping, not authorization**. The platform itself already -guarantees the bot only receives events from channels/groups it is a member of -with read permission — you cannot receive a message from a channel you were never -added to. So `allowed_channels` does not defend against "unauthorized channels" -(L1/the platform already does); it only narrows an over-permissioned bot to the -surfaces an operator wants it active in. Its value is noise/cost control. - -**Default: OPEN** (`allow_all_channels = true`, `allow_dm = true`). Operators -*disable* surfaces only for hard scoping (e.g. a group-only bot sets -`allow_dm = false`). - -**DMs are an L2 surface with a critical asymmetry:** unlike groups, a DM has **no -platform membership gate** — anyone can open a DM with a public bot. So when -`allow_dm = true`, the **only** protection on that path is L3. Enabling the DM -surface is an L2 decision; guarding who may use it is L3. - -### Layer 3: Identity Trust Control (core layer) ← This ADR — the SECURITY gate +- **Multi-gateway deployments** — running Telegram + LINE simultaneously has unclear semantics -Controls which individual senders can trigger agent actions. Currently defaults -to allow-all; this ADR flips it to **deny-all**. This is the one authorization -boundary at the policy layer, and it covers **every** ingress path — including -DMs, where it is the sole protection. +## 3. Decision -**Why L2 must stay open for the deny UX to work:** the "echo your UID so you can -request access" reply only fires if an untrusted sender's message actually -*reaches* L3. If L2 defaulted closed (e.g. `allow_dm = false`), a new user would -be silently dropped at the scope layer with no path to onboard. L2-open + L3-deny -gives the intended self-service flow: +### 3.1 Per-platform top-level config sections -``` -stranger messages the bot - → L1 ✅ authentic platform request - → L2 ✅ surface open by default (channel / DM) - → L3 ❌ identity not in allowed_users - → echo "⚠️ You're not trusted. Your ID: 123456789. Ask the admin to add you." - → drop — no agent action -``` - -This flips **only L3** from today's allow-all to deny-all; L2 stays open. Minimal -breaking surface, maximal safety: nothing acts for an untrusted identity, yet -strangers still get a way to request access. - ---- - -## 4. Decision - -### 4.1 Per-platform top-level config sections - -Every platform gets its own section with platform-specific settings + unified trust fields: +Every platform gets its own section with platform-specific settings + unified fields (`allowed_users`, `allow_all_users`, `allowed_channels`, …). Per-platform trust semantics are specified in the [Identity Trust-None ADR](identity-trust-none.md). ```toml [discord] @@ -181,202 +80,7 @@ allowed_tenants = ["tenant-uuid"] allowed_users = ["29:1abc..."] ``` -### 4.2 Trust-none default - -``` -Current: empty allowed_users → allow_all_users = true (TRUST ALL) -Proposed: empty allowed_users → allow_all_users = false (TRUST NONE) -``` - -When a message arrives from an untrusted sender: -1. Log the event (sender ID, platform, timestamp) -2. Reply with an echo message showing the sender their own ID -3. Do NOT dispatch to any agent - -### 4.3 Trust check at router level (single gate) - -Trust enforcement happens in **one place only**: `AdapterRouter::handle_message()`. The gateway remains a pure transport layer (L1 only). - ---- - -## 5. Architecture - -``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Telegram │ │ LINE │ │ Feishu │ │ WeCom / GC │ -│ Webhook │ │ Webhook │ │ WebSocket │ │ Webhook │ -└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ openab-gateway — L1: Platform Authentication │ -│ │ -│ ✅ Verify webhook signature / JWT / secret token / IP │ -│ ✅ Normalize → GatewayEvent │ -│ ✅ Forward ALL authenticated events │ -│ ❌ No user filtering (L3 is in core) │ -└────────────────────────────┬────────────────────────────────────────┘ - │ WebSocket - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ openab-core — L2 + L3 │ -│ │ -│ ┌───────────┐ ┌───────────┐ ┌─────────────────────────────┐ │ -│ │ Discord │ │ Slack │ │ GatewayAdapter │ │ -│ │ Handler │ │ Handler │ │ (TG/LINE/Feishu/WeCom/GC) │ │ -│ └─────┬─────┘ └─────┬─────┘ └──────────────┬──────────────┘ │ -│ └──────────────┼──────────────────────┘ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────────────────────┐ │ -│ │ 🔒 AdapterRouter::handle_message() │ │ -│ │ │ │ -│ │ L2: scope check (optional, default-open; channel/group/DM) │ │ -│ │ L3: TrustConfig::is_allowed(platform, sender_id) — DENY dflt │ │ -│ │ │ │ -│ │ if denied → log + echo sender ID → RETURN │ │ -│ │ if allowed → dispatch to ACP ✅ │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ ACP Session │ │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### Per-platform TrustConfig - -```rust -pub struct TrustConfig { - // L2 — scope control (NOT security). Defaults OPEN. - pub allow_all_channels: bool, // default true - pub allowed_channels: HashSet, - pub allow_dm: bool, // default true (DM surface open) - - // L3 — identity trust (THE security gate). Defaults DENY-ALL. - pub allow_all_users: bool, // explicit opt-in, default false - pub allowed_users: HashSet, -} - -impl TrustConfig { - /// L2: is this conversation surface in scope? (default-open) - pub fn surface_allowed(&self, channel_id: &str, is_dm: bool) -> bool { - if is_dm { - return self.allow_dm; - } - self.allow_all_channels || self.allowed_channels.contains(channel_id) - } - - /// L3: is this identity trusted? (default-deny) - pub fn is_allowed(&self, sender_id: &str) -> bool { - self.allow_all_users || self.allowed_users.contains(sender_id) - } -} - -/// Router holds one TrustConfig per platform -pub struct PlatformTrustConfigs { - configs: HashMap, // keyed by platform name -} - -impl PlatformTrustConfigs { - pub fn get(&self, platform: &str) -> &TrustConfig { - self.configs.get(platform).unwrap_or(&DEFAULT) - } -} - -/// Default: L2 open (act anywhere the platform allows), L3 deny-all. -static DEFAULT: TrustConfig = TrustConfig { - allow_all_channels: true, - allowed_channels: HashSet::new(), - allow_dm: true, - allow_all_users: false, // trust-none on identity - allowed_users: HashSet::new(), -}; -``` - -### Trait & Type Changes (no new trait) - -The trust gate is **uniform logic**, not per-platform behavior, so it is a plain -`TrustConfig` + a router method — **not** a `ChatAdapter` method and **not** a new -trait (see Rejected Alternatives). The `ChatAdapter` trait is unchanged: -`platform()` already keys the `TrustConfig` and `send_message()` already performs -the echo. What changes are the **shared data carriers** that feed the router: - -**1. `MessageContext` — carry structured sender identity (not opaque JSON).** -Today the router only receives `sender_json` (a serialized blob); it would have to -parse JSON to read `sender_id`. Pass the `SenderContext` struct so L3 can read -`sender_id` / `is_bot` directly (the router can still serialize it for the agent): - -```rust -pub struct MessageContext { - pub thread_channel: ChannelRef, - pub sender: SenderContext, // ← was: sender_json: String - pub prompt: String, - pub extra_blocks: Vec, - pub trigger_msg: MessageRef, - pub other_bot_present: bool, -} -``` - -**2. `ChannelRef` — add an `is_dm` flag.** -DM detection is platform-specific *structural* knowledge the adapter already has at -construction time (Discord DM channel vs Telegram private chat vs Slack IM), so it -is a **field the adapter populates**, not a trait method. This lets the router -evaluate `allow_dm` (L2) uniformly: - -```rust -pub struct ChannelRef { - pub platform: String, - pub channel_id: String, - pub is_dm: bool, // ← new; excluded from Hash/Eq like origin_event_id - pub thread_id: Option, - pub parent_id: Option, - pub origin_event_id: Option, -} -``` - -**3. Remove scattered trust checks from adapters.** -The real refactor is deleting the `allowed_channels` / `allowed_users` checks -currently in `discord.rs`, `slack.rs`, and `gateway.rs`, and letting the data flow -into `MessageContext` / `ChannelRef` so the single router gate is the only place -trust is enforced — this is what makes L3 un-bypassable. Structural concerns -(thread detection, @mention gating, multibot detection, bot-ownership) **stay in -the adapters** — they are not trust. - -### Echo reply on deny - -```rust -// In AdapterRouter::handle_message() -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; -``` - ---- - -## 6. Migration - -### Breaking change - -Existing deployments with no `allowed_users` configured will stop accepting messages. - -### Migration path - -```toml -# Before (implicit trust-all): -[discord] -bot_token = "..." - -# After (explicit trust-all to keep old behavior): -[discord] -bot_token = "..." -allow_all_users = true -``` - -### `[gateway]` deprecation +### 3.2 `[gateway]` deprecation The `[gateway]` section remains functional for backward compatibility but is deprecated: @@ -391,9 +95,7 @@ allowed_users = ["123"] allowed_users = ["123"] ``` ---- - -## 7. Sender ID Formats +## 4. Sender ID Formats | Platform | Config section | ID format | Example | |----------|---------------|-----------|---------| @@ -406,41 +108,33 @@ allowed_users = ["123"] | Google Chat | `[googlechat]` | User resource name | `users/123456789` | | MS Teams | `[teams]` | AAD Object ID | `29:1abc...` | ---- - -## 8. Implementation Plan +## 5. Migration -1. **Define `TrustConfig` struct** and `PlatformTrustConfigs` in `openab-core` -2. **Add per-platform config parsing** — each `[platform]` section reads `allowed_users` and `allow_all_users` -3. **Wire trust gate into `AdapterRouter::handle_message()`** — single check point -4. **Remove scattered trust checks** from: - - `is_denied_user()` in Discord EventHandler - - `should_skip_event()` user filter in `gateway.rs` - - `allowed_users` check in Feishu gateway adapter -5. **Add echo reply** on deny using `ChatAdapter::send_message()` -6. **Deprecation warning** for `[gateway].allowed_users` -7. **Update `config.toml.example`** and docs -8. **Migration guide** in release notes - ---- +```toml +# Before — gateway catch-all: +[gateway] +platform = "telegram" +allowed_users = ["123456789"] -## 9. Rejected Alternatives +# After — first-class section: +[telegram] +bot_token = "${TELEGRAM_BOT_TOKEN}" +secret_token = "${TELEGRAM_SECRET_TOKEN}" +allowed_users = ["123456789"] +``` -### Per-adapter `InboundGate` trait +The `[gateway]` section continues to work (with a deprecation warning) for one +release cycle to give deployments time to migrate. -Each adapter implements `is_trusted_sender()`. Rejected because: -- Trust logic is identical across all platforms (`allowed_users.contains(id)`) -- Forces N identical implementations with no polymorphic benefit -- New adapter forgetting to implement = security hole -- Router-level gate is impossible to bypass by construction +## 6. Implementation Plan -### Trust check at gateway layer +1. **Add per-platform config structs** — `[telegram]`, `[line]`, `[feishu]`, `[wecom]`, `[googlechat]`, `[teams]` parsed as top-level sections +2. **Map gateway events to the owning platform's config** by `platform` name +3. **Deprecation warning** when `[gateway]` is present +4. **Update `config.toml.example`** and per-platform docs +5. **Migration guide** in release notes -Gateway adapters filter untrusted senders before forwarding. Rejected because: -- Gateway is transport (L1) — mixing L3 policy violates layer separation -- Trust config lives in core's `config.toml`, not gateway env vars -- Would split config into two places -- Reply capability already wired in core via `ChatAdapter::send_message()` +## 7. Rejected Alternatives ### Keep `[gateway]` with per-platform sub-sections @@ -449,4 +143,5 @@ Gateway adapters filter untrusted senders before forwarding. Rejected because: allowed_users = [...] ``` -Rejected because it still treats gateway platforms as subordinate. A `[telegram]` section is more intuitive and symmetric with `[discord]` / `[slack]`. +Rejected because it still treats gateway platforms as subordinate. A `[telegram]` +section is more intuitive and symmetric with `[discord]` / `[slack]`.