Skip to content

Commit 22a3cb2

Browse files
authored
feat(trust): Phase 1 (gateway) — route gateway ingress through the shared gate (#1267)
* feat(trust): Phase 1 (gateway) — route gateway ingress through shared trust gate Wires the shared L2/L3 gate (#1266) into the unified gateway path: - AdapterRouter gains a PlatformTrustConfigs registry (via with_trust builder — new()'s signature unchanged) + gate_incoming() ingress gate - process_gateway_event now enforces L2/L3 via router.gate_incoming(); should_skip_event keeps only bot-filter + @mention gating (its channel/user checks are neutered in the unified path) - registry built at startup from GATEWAY_* env, keyed per gateway platform Behavior-preserving: registry defaults mirror today's should_skip_event (allow-all default); is_dm passed false so DMs are evaluated as channels exactly as today. Discord/Slack routing + dispatch-path privatization + should_skip_event L2/L3 removal follow in later PRs. Deny-flip is Phase 3. Refs #1264 * docs(trust): add phase-2 TODO for is_dm carrier at gateway gate (review F2) --------- Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent 5812879 commit 22a3cb2

3 files changed

Lines changed: 114 additions & 19 deletions

File tree

crates/openab-core/src/adapter.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,10 @@ pub struct AdapterRouter {
443443
workspace_aliases: std::collections::HashMap<String, String>,
444444
/// Bot home directory (security boundary for workspace directives).
445445
bot_home: std::path::PathBuf,
446+
/// Per-platform trust gate (L2 scope + L3 identity). Populated via
447+
/// [`AdapterRouter::with_trust`]; empty default = deny-all per platform
448+
/// (only consulted by paths wired to the gate — currently the gateway path).
449+
trust: crate::trust::PlatformTrustConfigs,
446450
}
447451

448452
impl AdapterRouter {
@@ -472,9 +476,32 @@ impl AdapterRouter {
472476
liveness_check_interval: std::time::Duration::from_secs(liveness_check_secs),
473477
workspace_aliases,
474478
bot_home,
479+
trust: crate::trust::PlatformTrustConfigs::default(),
475480
}
476481
}
477482

483+
/// Attach the per-platform trust registry (builder style, before `Arc`-wrapping).
484+
/// Keeps `new()`'s signature stable across its many call sites.
485+
pub fn with_trust(mut self, trust: crate::trust::PlatformTrustConfigs) -> Self {
486+
self.trust = trust;
487+
self
488+
}
489+
490+
/// The single ingress trust gate: evaluate L2 (scope) + L3 (identity) for an
491+
/// inbound message. This is the long-term choke point — dispatch paths should
492+
/// only be reachable after an `Allow` here. Returns the [`Decision`] so the
493+
/// caller can echo on `DenyIdentity` (request-access UX) vs silently drop on
494+
/// `DenyScope`.
495+
pub fn gate_incoming(
496+
&self,
497+
platform: &str,
498+
channel_id: &str,
499+
is_dm: bool,
500+
sender_id: &str,
501+
) -> crate::trust::Decision {
502+
self.trust.decide(platform, channel_id, is_dm, sender_id)
503+
}
504+
478505
/// Access the underlying session pool (e.g. for config option queries).
479506
pub fn pool(&self) -> &Arc<SessionPool> {
480507
&self.pool

crates/openab-core/src/gateway.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,12 +1147,16 @@ pub async fn process_gateway_event(
11471147
let event: GatewayEvent = serde_json::from_str(event_json)
11481148
.map_err(|e| anyhow::anyhow!("invalid gateway event JSON: {e}"))?;
11491149

1150-
// Shared filter logic
1150+
// Structural gating (bot filter + @mention) stays in should_skip_event.
1151+
// L2 (channel) + L3 (identity) are now enforced by the shared ingress gate
1152+
// (`router.gate_incoming`) below, so we neuter should_skip_event's channel/user
1153+
// checks here by passing allow-all for them.
1154+
let no_ids: HashSet<String> = HashSet::new();
11511155
let filter = EventFilterParams {
1152-
allow_all_channels: ctx.allow_all_channels,
1153-
allowed_channels: &ctx.allowed_channels,
1154-
allow_all_users: ctx.allow_all_users,
1155-
allowed_users: &ctx.allowed_users,
1156+
allow_all_channels: true,
1157+
allowed_channels: &no_ids,
1158+
allow_all_users: true,
1159+
allowed_users: &no_ids,
11561160
allow_bot_messages: ctx.allow_bot_messages,
11571161
trusted_bot_ids: &ctx.trusted_bot_ids,
11581162
bot_username: ctx.bot_username.as_deref(),
@@ -1161,6 +1165,27 @@ pub async fn process_gateway_event(
11611165
return Ok(false);
11621166
}
11631167

1168+
// Shared ingress trust gate (L2 scope + L3 identity), keyed by platform.
1169+
// Phase 1: `is_dm = false` preserves today's behavior where gateway DMs are
1170+
// evaluated against the channel allowlist like any other channel (the
1171+
// `allow_dm` surface semantics arrive with the per-platform trust flip).
1172+
// TODO(phase-2): derive is_dm from the event/ChannelRef carrier so the
1173+
// `allow_dm` L2 surface can be enforced and tested for gateway platforms.
1174+
let decision =
1175+
ctx.router
1176+
.gate_incoming(&event.platform, &event.channel.id, false, &event.sender.id);
1177+
if !decision.is_allowed() {
1178+
tracing::info!(
1179+
platform = %event.platform,
1180+
sender = %event.sender.id,
1181+
channel = %event.channel.id,
1182+
?decision,
1183+
"gateway event denied by trust gate"
1184+
);
1185+
// Phase 2 will echo the sender their ID on Decision::DenyIdentity.
1186+
return Ok(false);
1187+
}
1188+
11641189
tracing::info!(
11651190
platform = %event.platform,
11661191
sender = %event.sender.name,

src/main.rs

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -256,21 +256,64 @@ async fn main() -> anyhow::Result<()> {
256256
info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled");
257257
}
258258

259-
let router = Arc::new(AdapterRouter::new(
260-
pool.clone(),
261-
cfg.reactions,
262-
cfg.markdown.tables,
263-
cfg.pool.prompt_hard_timeout_secs,
264-
cfg.pool.liveness_check_secs,
265-
cfg.workspace.aliases,
266-
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| {
267-
tracing::warn!(
268-
"HOME environment variable is not set — falling back to /tmp as bot_home. \
269-
This weakens the workspace security boundary."
259+
// Build the per-platform trust registry for the gateway platforms from the
260+
// same GATEWAY_* env the unified bridge uses (behavior-preserving: defaults
261+
// allow-all, matching today's should_skip_event). L2/L3 enforcement moves to
262+
// the router's ingress gate; should_skip_event keeps only bot + @mention
263+
// gating for the unified path. Discord/Slack are wired in a later PR.
264+
let gateway_trust = {
265+
use openab_core::trust::{PlatformTrustConfigs, TrustConfig};
266+
let env_bool = |k: &str, default: bool| {
267+
std::env::var(k)
268+
.map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
269+
.unwrap_or(default)
270+
};
271+
let env_set = |k: &str| -> Vec<String> {
272+
std::env::var(k)
273+
.unwrap_or_default()
274+
.split(',')
275+
.map(|s| s.trim().to_string())
276+
.filter(|s| !s.is_empty())
277+
.collect()
278+
};
279+
let allow_all_channels = env_bool("GATEWAY_ALLOW_ALL_CHANNELS", true);
280+
let allowed_channels = env_set("GATEWAY_ALLOWED_CHANNELS");
281+
let allow_all_users = env_bool("GATEWAY_ALLOW_ALL_USERS", true);
282+
let allowed_users = env_set("GATEWAY_ALLOWED_USERS");
283+
let mut reg = PlatformTrustConfigs::new();
284+
for platform in ["telegram", "line", "feishu", "wecom", "googlechat", "teams"] {
285+
reg.insert(
286+
platform,
287+
TrustConfig::new(
288+
Some(allow_all_channels),
289+
allowed_channels.clone(),
290+
None, // allow_dm unused in Phase 1 (is_dm passed as false)
291+
Some(allow_all_users),
292+
allowed_users.clone(),
293+
),
270294
);
271-
"/tmp".into()
272-
})),
273-
));
295+
}
296+
reg
297+
};
298+
299+
let router = Arc::new(
300+
AdapterRouter::new(
301+
pool.clone(),
302+
cfg.reactions,
303+
cfg.markdown.tables,
304+
cfg.pool.prompt_hard_timeout_secs,
305+
cfg.pool.liveness_check_secs,
306+
cfg.workspace.aliases,
307+
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| {
308+
tracing::warn!(
309+
"HOME environment variable is not set — falling back to /tmp as bot_home. \
310+
This weakens the workspace security boundary."
311+
);
312+
"/tmp".into()
313+
})),
314+
)
315+
.with_trust(gateway_trust),
316+
);
274317

275318
// Shutdown signal for Slack adapter
276319
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);

0 commit comments

Comments
 (0)