diff --git a/config.toml.example b/config.toml.example index 28b8bb70a..f1621428c 100644 --- a/config.toml.example +++ b/config.toml.example @@ -85,6 +85,25 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # allowed_users = ["U1234567890abcdef0123456789abcdef"] # LINE user IDs (U…, 33 chars) # # env fallback: LINE_ALLOWED_USERS (comma-separated) +# --- WeCom / Google Chat / MS Teams (first-class trust sections) --- +# Same shape as [line]: L3 identity trust per platform, replacing the uniform +# GATEWAY_ALLOW_ALL_USERS / GATEWAY_ALLOWED_USERS env for that platform +# (deprecated: warns at startup; becomes an error in Phase 2). Platform +# credentials stay on their gateway env vars (WECOM_CORP_ID/WECOM_SECRET, +# GOOGLE_CHAT_*, TEAMS_APP_ID/TEAMS_APP_SECRET). +# [wecom] +# allow_all_users = false # env fallback: WECOM_ALLOW_ALL_USERS +# allowed_users = ["zhangsan", "lisi"] # WeCom UserIDs (tenant-assigned, freeform strings) +# # env fallback: WECOM_ALLOWED_USERS (comma-separated) +# [googlechat] +# allow_all_users = false # env fallback: GOOGLE_CHAT_ALLOW_ALL_USERS +# allowed_users = ["users/123456789"] # Chat user resource names (users/) +# # env fallback: GOOGLE_CHAT_ALLOWED_USERS (comma-separated) +# [teams] +# allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS +# allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…) +# # env fallback: TEAMS_ALLOWED_USERS (comma-separated) + # --- AgentCore Runtime (alternative to [agent]) --- # When [agentcore] is set and [agent].command is not explicitly provided, # OAB auto-spawns the bundled agentcore-acp adapter. No manual wiring needed. diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 06c05b597..c4d82e44c 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -173,6 +173,9 @@ pub struct Config { pub gateway: Option, pub telegram: Option, pub line: Option, + pub wecom: Option, + pub googlechat: Option, + pub teams: Option, pub agentcore: Option, #[serde(default)] pub agent: AgentConfig, @@ -833,6 +836,76 @@ impl LineConfig { } } +/// Shared first-class trust section for gateway platforms whose Phase 1 needs +/// exactly the two L3 identity fields (identity-trust-none ADR): `[wecom]`, +/// `[googlechat]`, `[teams]`. Same shape and resolution order as +/// [`LineConfig`] / [`TelegramConfig`]: `[section].field` (with `${}` +/// expansion) → `{PREFIX}_*` env var → deny-all default. +/// +/// Trust-only by design — platform credentials stay on the gateway env vars +/// the webhook adapters read (`WECOM_CORP_ID`/`WECOM_SECRET`, +/// `GOOGLE_CHAT_*`, `TEAMS_APP_ID`/`TEAMS_APP_SECRET`). Platforms that later +/// need extra trust fields (e.g. `trusted_bot_ids`) graduate to their own +/// struct, as LINE will for group policy. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct PlatformTrustConfig { + /// Explicit flag: true = allow all users, false = check `allowed_users`. + /// When not set, defaults to `false` (deny-all, per identity-trust-none ADR). + /// Env fallback: `{PREFIX}_ALLOW_ALL_USERS` (empty string treated as unset). + /// + /// **Note:** When this resolves to `true`, the `allowed_users` list is + /// bypassed entirely — all users are permitted regardless of list contents. + pub allow_all_users: Option, + /// Platform user IDs allowed to interact with the bot. Only checked when + /// `allow_all_users` resolves to `false`. Env fallback: + /// `{PREFIX}_ALLOWED_USERS` (comma-separated). + /// `None` = not set (fall back to env); `Some([])` = explicit empty (deny all). + pub allowed_users: Option>, +} + +/// Fully resolved platform trust settings (config → env → default applied). +#[derive(Debug, Clone)] +pub struct ResolvedPlatformTrust { + pub allow_all_users: bool, + pub allowed_users: Vec, +} + +impl PlatformTrustConfig { + /// Resolve both fields against `{prefix}_ALLOW_ALL_USERS` / + /// `{prefix}_ALLOWED_USERS` env fallbacks (e.g. prefix `"WECOM"`, + /// `"GOOGLE_CHAT"`, `"TEAMS"`). + pub fn resolve_with_env(&self, prefix: &str) -> ResolvedPlatformTrust { + let allowed_users: Vec = match &self.allowed_users { + Some(list) => list.clone(), + None => std::env::var(format!("{prefix}_ALLOWED_USERS")) + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }; + ResolvedPlatformTrust { + allow_all_users: self.allow_all_users.unwrap_or_else(|| { + std::env::var(format!("{prefix}_ALLOW_ALL_USERS")) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(false) + }), + allowed_users, + } + } + + /// Whether first-class trust env vars exist for `prefix` — used by the + /// binary to decide whether the platform's trust still rides on the + /// deprecated uniform `GATEWAY_*` seed (and to warn when it does). + pub fn env_trust_present(prefix: &str) -> bool { + std::env::var(format!("{prefix}_ALLOW_ALL_USERS")).is_ok() + || std::env::var(format!("{prefix}_ALLOWED_USERS")).is_ok() + } +} + /// Raw intermediate struct for serde — uses `Option` to detect explicit fields. #[derive(Debug, Deserialize)] #[serde(default)] @@ -1987,6 +2060,126 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] std::env::remove_var("LINE_ALLOWED_USERS"); } + #[test] + fn platform_trust_sections_parse_from_toml() { + let toml_str = r#" +[discord] +bot_token = "x" + +[wecom] +allowed_users = ["zhangsan", "lisi"] + +[googlechat] +allowed_users = ["users/123456789"] + +[teams] +allow_all_users = true +"#; + let cfg = parse_config_str(toml_str, "test").unwrap(); + let wecom = cfg.wecom.expect("wecom section"); + assert_eq!( + wecom.allowed_users.as_deref(), + Some(&["zhangsan".to_string(), "lisi".to_string()][..]) + ); + assert_eq!(wecom.allow_all_users, None); + let gc = cfg.googlechat.expect("googlechat section"); + assert_eq!( + gc.allowed_users.as_deref(), + Some(&["users/123456789".to_string()][..]) + ); + let teams = cfg.teams.expect("teams section"); + assert_eq!(teams.allow_all_users, Some(true)); + + // Absent sections → None (trust falls back to legacy GATEWAY_* seed). + let cfg = parse_config_str("[discord]\nbot_token = \"x\"\n", "test").unwrap(); + assert!(cfg.wecom.is_none()); + assert!(cfg.googlechat.is_none()); + assert!(cfg.teams.is_none()); + } + + /// All `WECOM_*` env scenarios in ONE test — std::env is process-global and + /// cargo runs tests in parallel (same pattern as + /// `line_resolve_all_scenarios`). The WECOM prefix stands in for all + /// `PlatformTrustConfig` users; the prefix is a plain format argument, so + /// GOOGLE_CHAT/TEAMS behave identically by construction. + #[test] + fn platform_trust_resolve_all_scenarios() { + // --- Scenario 1: defaults — deny-all per identity-trust-none ADR --- + std::env::remove_var("WECOM_ALLOW_ALL_USERS"); + std::env::remove_var("WECOM_ALLOWED_USERS"); + let r = PlatformTrustConfig::default().resolve_with_env("WECOM"); + assert!(!r.allow_all_users); + assert!(r.allowed_users.is_empty()); + assert!(!PlatformTrustConfig::env_trust_present("WECOM")); + + // --- Scenario 2: config wins over env --- + std::env::set_var("WECOM_ALLOW_ALL_USERS", "true"); + std::env::set_var("WECOM_ALLOWED_USERS", "mallory"); // ignored — config list wins + let cfg = PlatformTrustConfig { + allow_all_users: Some(false), + allowed_users: Some(vec!["zhangsan".into()]), + }; + let r = cfg.resolve_with_env("WECOM"); + assert!(!r.allow_all_users); + assert_eq!(r.allowed_users, vec!["zhangsan".to_string()]); + + // --- Scenario 3: env fallback (comma-separated, trimmed, empties dropped) --- + std::env::set_var("WECOM_ALLOWED_USERS", " zhangsan , lisi,,wangwu "); + let r = PlatformTrustConfig::default().resolve_with_env("WECOM"); + assert!(r.allow_all_users); // from WECOM_ALLOW_ALL_USERS=true + assert_eq!( + r.allowed_users, + vec![ + "zhangsan".to_string(), + "lisi".to_string(), + "wangwu".to_string() + ] + ); + assert!(PlatformTrustConfig::env_trust_present("WECOM")); + + // --- Scenario 4: empty-string env flag treated as unset → deny-all --- + std::env::set_var("WECOM_ALLOW_ALL_USERS", ""); + std::env::remove_var("WECOM_ALLOWED_USERS"); + assert!( + !PlatformTrustConfig::default() + .resolve_with_env("WECOM") + .allow_all_users + ); + + // --- Scenario 5: "0"/"false" env values resolve false --- + std::env::set_var("WECOM_ALLOW_ALL_USERS", "0"); + assert!( + !PlatformTrustConfig::default() + .resolve_with_env("WECOM") + .allow_all_users + ); + std::env::set_var("WECOM_ALLOW_ALL_USERS", "false"); + assert!( + !PlatformTrustConfig::default() + .resolve_with_env("WECOM") + .allow_all_users + ); + + // --- Scenario 6: explicit empty config list = deny-all, ignores env --- + std::env::set_var("WECOM_ALLOWED_USERS", "mallory"); + let cfg = PlatformTrustConfig { + allow_all_users: None, + allowed_users: Some(vec![]), + }; + assert!(cfg.resolve_with_env("WECOM").allowed_users.is_empty()); + + // --- Scenario 7: prefixes are independent — WECOM env must not bleed + // into another prefix --- + assert!(!PlatformTrustConfig::env_trust_present("TEAMS")); + assert!(PlatformTrustConfig::default() + .resolve_with_env("TEAMS") + .allowed_users + .is_empty()); + + std::env::remove_var("WECOM_ALLOW_ALL_USERS"); + std::env::remove_var("WECOM_ALLOWED_USERS"); + } + #[test] fn hooks_any_configured_false_when_empty() { let h = HooksConfig::default(); diff --git a/docs/config-reference.md b/docs/config-reference.md index 938c9fe3d..773c61809 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -139,6 +139,8 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl First-class L3 identity trust for the LINE adapter (identity-trust-none ADR, Phase 1). Replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for LINE — relying on those for LINE is deprecated and warns at startup. Channel credentials remain on the `LINE_CHANNEL_SECRET` / `LINE_CHANNEL_ACCESS_TOKEN` env vars. +> **Mode scoping:** takes effect on the **embedded/unified adapter path** (see the note under `[wecom]` / `[googlechat]` / `[teams]` below — the same applies here). + Each field resolves: config value → `LINE_*` env var → default (deny-all). | Key | Type | Default | Description | @@ -154,6 +156,41 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] --- +## `[wecom]` / `[googlechat]` / `[teams]` + +First-class L3 identity trust for the remaining gateway platforms — same shape and semantics as `[line]`. Each section replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for its platform (deprecated: warns at startup, becomes an error in Phase 2). Platform credentials remain on the gateway env vars (`WECOM_CORP_ID`/`WECOM_SECRET`, `GOOGLE_CHAT_*`, `TEAMS_APP_ID`/`TEAMS_APP_SECRET`). + +> **Mode scoping:** these sections (like `[line]`) take effect on the **embedded/unified adapter path**, where events pass the shared ingress trust gate. Deployments using the standalone `openab-gateway` companion over WebSocket enforce trust via `[gateway].allow_all_users` / `allowed_users` instead; Phase 1c consolidates the two paths. + +Each field resolves: config value → `{PREFIX}_*` env var → default (deny-all). Env prefixes: `WECOM`, `GOOGLE_CHAT`, `TEAMS`. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `allow_all_users` | bool \| omit | `false` (deny-all) | `true` = any user may interact (bypasses `allowed_users` entirely). Env fallback: `{PREFIX}_ALLOW_ALL_USERS`. | +| `allowed_users` | string[] | `[]` | Platform user IDs allowed to interact. Only checked when `allow_all_users` resolves to false. Env fallback: `{PREFIX}_ALLOWED_USERS` (comma-separated). | + +Sender ID formats per platform: + +| Platform | Sender ID format | Example | +|----------|-----------------|---------| +| WeCom | Tenant-assigned UserID (freeform string) | `"zhangsan"` | +| Google Chat | User resource name | `"users/123456789"` | +| MS Teams | Bot Framework `activity.from.id` | `"29:1abc..."` | + +```toml +[wecom] +allowed_users = ["zhangsan", "lisi"] + +[googlechat] +allowed_users = ["users/123456789"] + +[teams] +allowed_users = ["29:1abc..."] +# allow_all_users = true # explicit opt-in only +``` + +--- + ## `[agent]` The AI agent subprocess that OpenAB spawns to handle messages via ACP. diff --git a/docs/google-chat.md b/docs/google-chat.md index a667d50b9..25478f786 100644 --- a/docs/google-chat.md +++ b/docs/google-chat.md @@ -152,6 +152,22 @@ allow_all_users = true [agent] ``` +### User Trust (`[googlechat]` section) + +> **Mode scoping:** the `[googlechat]` section applies when the Google Chat adapter is **embedded in the OAB binary** (unified mode, `GOOGLE_CHAT_ENABLED=true` env set on the OAB container). In the standalone-gateway mode shown above, trust is enforced by `[gateway].allow_all_users` / `allowed_users` instead — the `[googlechat]` section has no effect on that path yet (Phase 1c consolidates the two). + +Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[googlechat]` section: + +```toml +[googlechat] +allowed_users = ["users/123456789"] # Chat user resource names (users/) +# allow_all_users = true # explicit opt-in only — any user can drive the agent +``` + +Each field falls back to its `GOOGLE_CHAT_ALLOW_ALL_USERS` / `GOOGLE_CHAT_ALLOWED_USERS` env var when unset. + +> ⚠️ **Deprecated:** driving Google Chat trust through the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars still works but logs a startup warning; it will become a startup error in a later phase. Migrate to `[googlechat]` (or `GOOGLE_CHAT_*` env vars). + ## Features ### Supported diff --git a/docs/line.md b/docs/line.md index 5054d491c..5a6497e60 100644 --- a/docs/line.md +++ b/docs/line.md @@ -104,6 +104,8 @@ platform = "line" ### User Trust (`[line]` section) +> **Mode scoping:** the `[line]` section applies when the LINE adapter is **embedded in the OAB binary** (unified mode, `LINE_CHANNEL_SECRET` env set on the OAB container). In the standalone-gateway mode shown above, trust is enforced by `[gateway].allow_all_users` / `allowed_users` instead — the `[line]` section has no effect on that path yet (Phase 1c consolidates the two). + Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[line]` section: ```toml diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 08f1ec748..23ca49cc6 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -31,6 +31,22 @@ tables = "off" Set `TEAMS_APP_ID` and `TEAMS_APP_SECRET` on the container. No `[gateway]` needed. +### User Trust (`[teams]` section) + +Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[teams]` section: + +```toml +[teams] +allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values +# allow_all_users = true # explicit opt-in only — any user can drive the agent +``` + +Each field falls back to its `TEAMS_ALLOW_ALL_USERS` / `TEAMS_ALLOWED_USERS` env var when unset. To find a user's `29:…` ID, check the OAB logs — the sender ID is logged for each incoming message. + +> **Mode scoping:** the `[teams]` section applies to this unified (embedded) mode. In the legacy standalone-gateway mode below, trust is enforced by `[gateway].allow_all_users` / `allowed_users` instead — the `[teams]` section has no effect on that path yet (Phase 1c consolidates the two). + +> ⚠️ **Deprecated:** driving Teams trust through the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars still works but logs a startup warning; it will become a startup error in a later phase. Migrate to `[teams]` (or `TEAMS_*` env vars). + --- ## Standalone Gateway Mode (Legacy) diff --git a/docs/wecom.md b/docs/wecom.md index 7627b6f64..8c6737613 100644 --- a/docs/wecom.md +++ b/docs/wecom.md @@ -129,6 +129,22 @@ max_sessions = 10 | `allow_all_channels` | No | Allow messages from all channels (default: `false`) | | `allow_all_users` | No | Allow messages from all users (default: `false`) | +### User Trust (`[wecom]` section) + +> **Mode scoping:** the `[wecom]` section applies when the WeCom adapter is **embedded in the OAB binary** (unified mode, `WECOM_CORP_ID` env set on the OAB container). In the standalone-gateway mode shown above, trust is enforced by `[gateway].allow_all_users` / `allowed_users` instead — the `[wecom]` section has no effect on that path yet (Phase 1c consolidates the two). + +Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[wecom]` section: + +```toml +[wecom] +allowed_users = ["zhangsan", "lisi"] # WeCom UserIDs (tenant-assigned, freeform strings) +# allow_all_users = true # explicit opt-in only — any user can drive the agent +``` + +Each field falls back to its `WECOM_ALLOW_ALL_USERS` / `WECOM_ALLOWED_USERS` env var when unset. + +> ⚠️ **Deprecated:** driving WeCom trust through the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars still works but logs a startup warning; it will become a startup error in a later phase. Migrate to `[wecom]` (or `WECOM_*` env vars). + ## 6. Expose the Gateway (HTTPS) WeCom requires a publicly accessible HTTPS URL for callbacks. diff --git a/src/main.rs b/src/main.rs index d08946e81..219688110 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,6 +129,62 @@ fn has_unified_platform_env() -> bool { .unwrap_or(false)) } +/// Apply a platform's first-class trust section to the registry, or — when the +/// platform is active but still trust-driven by the deprecated uniform +/// `GATEWAY_ALLOW_ALL_USERS`/`GATEWAY_ALLOWED_USERS` env — log the Phase 1 +/// deprecation warning (#1356). Shared by the `[wecom]`/`[googlechat]`/`[teams]` +/// overrides; same override shape as the bespoke `[telegram]`/`[line]` blocks. +/// +/// L2 (channels) stays on the shared gateway values passed in; L3 mirrors the +/// resolved section (config → `{env_prefix}_*` env → deny-all). +#[allow(clippy::too_many_arguments)] +fn platform_trust_override( + reg: &mut openab_core::trust::PlatformTrustConfigs, + platform: &str, + section: &Option, + env_prefix: &str, + platform_active: bool, + allow_all_channels: bool, + allowed_channels: &[String], +) { + use openab_core::trust::TrustConfig; + let resolved = if let Some(s) = section { + Some(s.resolve_with_env(env_prefix)) + } else if config::PlatformTrustConfig::env_trust_present(env_prefix) { + Some(config::PlatformTrustConfig::default().resolve_with_env(env_prefix)) + } else { + None + }; + match resolved { + Some(r) => { + reg.insert( + platform, + TrustConfig::new( + Some(allow_all_channels), + allowed_channels.to_vec(), + None, + Some(r.allow_all_users), + r.allowed_users, + ), + ); + } + None => { + let legacy_env_set = std::env::var("GATEWAY_ALLOW_ALL_USERS").is_ok() + || std::env::var("GATEWAY_ALLOWED_USERS").is_ok(); + if platform_active && legacy_env_set { + warn!( + platform, + "platform trust is driven by deprecated GATEWAY_ALLOW_ALL_USERS/\ + GATEWAY_ALLOWED_USERS env vars — migrate to a [{platform}] section \ + (allow_all_users/allowed_users) or {env_prefix}_ALLOW_ALL_USERS/\ + {env_prefix}_ALLOWED_USERS; the uniform GATEWAY_* fallback will \ + become a startup error in Phase 2 (#1356)" + ); + } + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -401,6 +457,11 @@ async fn main() -> anyhow::Result<()> { // gateway model yet (group policy is a follow-up), so it stays on the // shared GATEWAY_* values set above. // + // NOTE: deliberately NOT routed through platform_trust_override — + // LineConfig is a bespoke type that grows group-policy fields next + // (#1355 follow-up), unlike the shared PlatformTrustConfig used by + // wecom/googlechat/teams below. + // // Also resolves when running env-only (no [line] section but // LINE_ALLOW_ALL_USERS / LINE_ALLOWED_USERS set), matching the // Telegram pattern. @@ -444,6 +505,41 @@ async fn main() -> anyhow::Result<()> { } } } + + // WeCom / Google Chat / MS Teams: same first-class override shape as + // LINE above, via the shared [wecom]/[googlechat]/[teams] trust + // sections (#1358, #1359, #1360). L2 stays on the shared GATEWAY_* + // channel values; L3 mirrors the resolved per-platform section. + platform_trust_override( + &mut reg, + "wecom", + &cfg.wecom, + "WECOM", + cfg!(feature = "wecom") && std::env::var("WECOM_CORP_ID").is_ok(), + allow_all_channels, + &allowed_channels, + ); + platform_trust_override( + &mut reg, + "googlechat", + &cfg.googlechat, + "GOOGLE_CHAT", + cfg!(feature = "googlechat") + && std::env::var("GOOGLE_CHAT_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), + allow_all_channels, + &allowed_channels, + ); + platform_trust_override( + &mut reg, + "teams", + &cfg.teams, + "TEAMS", + cfg!(feature = "teams") && std::env::var("TEAMS_APP_ID").is_ok(), + allow_all_channels, + &allowed_channels, + ); reg };