use serde_json::Value;
use tandem_types::RequestPrincipal;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelKind {
Slack,
Discord,
Telegram,
}
impl ChannelKind {
pub fn as_str(self) -> &'static str {
match self {
ChannelKind::Slack => "slack",
ChannelKind::Discord => "discord",
ChannelKind::Telegram => "telegram",
}
}
}
#[derive(Debug, Clone)]
pub enum ChannelIdentityResolution {
Resolved(RequestPrincipal),
ChannelNotConfigured(ChannelKind),
Denied { kind: ChannelKind, user_id: String },
}
pub fn resolve_channel_user(
effective_config: &Value,
kind: ChannelKind,
surface_user_id: &str,
) -> ChannelIdentityResolution {
let user_id = surface_user_id.trim();
if user_id.is_empty() {
return ChannelIdentityResolution::Denied {
kind,
user_id: String::new(),
};
}
let channel_config = match effective_config.pointer(&format!("/channels/{}", kind.as_str())) {
Some(c) if !c.is_null() => c,
_ => return ChannelIdentityResolution::ChannelNotConfigured(kind),
};
let allowed_users = channel_config
.get("allowed_users")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !user_is_allowed(&allowed_users, user_id) {
return ChannelIdentityResolution::Denied {
kind,
user_id: user_id.to_string(),
};
}
ChannelIdentityResolution::Resolved(build_principal(kind, user_id))
}
pub fn channel_is_open_to_all(effective_config: &Value, kind: ChannelKind) -> bool {
effective_config
.pointer(&format!("/channels/{}/allowed_users", kind.as_str()))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.any(|entry| entry.trim() == "*")
})
.unwrap_or(false)
}
pub fn channel_bound_tenant(
effective_config: &Value,
kind: ChannelKind,
) -> Option<(String, String)> {
let tenant = effective_config.pointer(&format!("/channels/{}/tenant", kind.as_str()))?;
let org_id = tenant
.get("org_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let workspace_id = tenant
.get("workspace_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
Some((org_id, workspace_id))
}
fn user_is_allowed(allowlist: &[String], user_id: &str) -> bool {
if allowlist.is_empty() {
return false;
}
if allowlist.iter().any(|u| u == "*") {
return true;
}
allowlist.iter().any(|allowed| {
let allowed = allowed.trim();
allowed.eq_ignore_ascii_case(user_id)
|| allowed.eq_ignore_ascii_case(&format!("@{user_id}"))
})
}
fn build_principal(kind: ChannelKind, user_id: &str) -> RequestPrincipal {
RequestPrincipal {
actor_id: Some(format!("channel:{}:{}", kind.as_str(), user_id)),
source: format!("channel:{}", kind.as_str()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn resolves_slack_user_in_allowlist() {
let cfg = json!({
"channels": {
"slack": { "allowed_users": ["U12345", "U67890"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Slack, "U12345");
match result {
ChannelIdentityResolution::Resolved(principal) => {
assert_eq!(principal.actor_id.as_deref(), Some("channel:slack:U12345"));
assert_eq!(principal.source, "channel:slack");
}
other => panic!("expected Resolved, got {other:?}"),
}
}
#[test]
fn denies_slack_user_not_in_allowlist() {
let cfg = json!({
"channels": {
"slack": { "allowed_users": ["U12345"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Slack, "U99999");
assert!(matches!(result, ChannelIdentityResolution::Denied { .. }));
}
#[test]
fn allows_wildcard_allowlist() {
let cfg = json!({
"channels": {
"discord": { "allowed_users": ["*"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Discord, "1234567890");
assert!(matches!(result, ChannelIdentityResolution::Resolved(_)));
}
#[test]
fn empty_allowlist_denies_everyone() {
let cfg = json!({
"channels": {
"telegram": { "allowed_users": [] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Telegram, "12345");
assert!(matches!(result, ChannelIdentityResolution::Denied { .. }));
}
#[test]
fn missing_allowlist_denies_everyone() {
let cfg = json!({
"channels": {
"slack": { "bot_token": "xoxb-..." }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Slack, "U12345");
assert!(matches!(result, ChannelIdentityResolution::Denied { .. }));
}
#[test]
fn returns_channel_not_configured_when_section_missing() {
let cfg = json!({});
let result = resolve_channel_user(&cfg, ChannelKind::Slack, "U12345");
assert!(matches!(
result,
ChannelIdentityResolution::ChannelNotConfigured(ChannelKind::Slack)
));
}
#[test]
fn empty_user_id_is_denied() {
let cfg = json!({
"channels": {
"slack": { "allowed_users": ["*"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Slack, "");
assert!(matches!(result, ChannelIdentityResolution::Denied { .. }));
}
#[test]
fn whitespace_user_id_is_trimmed_and_resolved() {
let cfg = json!({
"channels": {
"slack": { "allowed_users": ["U12345"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Slack, " U12345 ");
assert!(matches!(result, ChannelIdentityResolution::Resolved(_)));
}
#[test]
fn allowlist_with_at_prefix_matches_unprefixed_user() {
let cfg = json!({
"channels": {
"telegram": { "allowed_users": ["@evan"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Telegram, "evan");
assert!(matches!(result, ChannelIdentityResolution::Resolved(_)));
}
#[test]
fn case_insensitive_match() {
let cfg = json!({
"channels": {
"discord": { "allowed_users": ["AliceBot"] }
}
});
let result = resolve_channel_user(&cfg, ChannelKind::Discord, "alicebot");
assert!(matches!(result, ChannelIdentityResolution::Resolved(_)));
}
#[test]
fn principal_actor_id_distinguishes_channel_kinds() {
let cfg = json!({
"channels": {
"slack": { "allowed_users": ["U12345"] },
"discord": { "allowed_users": ["U12345"] }
}
});
let slack = resolve_channel_user(&cfg, ChannelKind::Slack, "U12345");
let discord = resolve_channel_user(&cfg, ChannelKind::Discord, "U12345");
let slack_id = match slack {
ChannelIdentityResolution::Resolved(p) => p.actor_id.unwrap(),
_ => panic!("expected Resolved"),
};
let discord_id = match discord {
ChannelIdentityResolution::Resolved(p) => p.actor_id.unwrap(),
_ => panic!("expected Resolved"),
};
assert_ne!(slack_id, discord_id);
assert!(slack_id.starts_with("channel:slack:"));
assert!(discord_id.starts_with("channel:discord:"));
}
#[test]
fn channel_kind_str_matches_config_keys() {
assert_eq!(ChannelKind::Slack.as_str(), "slack");
assert_eq!(ChannelKind::Discord.as_str(), "discord");
assert_eq!(ChannelKind::Telegram.as_str(), "telegram");
}
#[test]
fn channel_is_open_to_all_detects_wildcard() {
let cfg = json!({
"channels": {
"slack": { "allowed_users": ["*"] },
"discord": { "allowed_users": ["U1", "U2"] },
"telegram": {}
}
});
assert!(channel_is_open_to_all(&cfg, ChannelKind::Slack));
assert!(!channel_is_open_to_all(&cfg, ChannelKind::Discord));
assert!(!channel_is_open_to_all(&cfg, ChannelKind::Telegram));
}
#[test]
fn channel_bound_tenant_parses_config_and_defaults_unbound() {
let cfg = json!({
"channels": {
"slack": { "tenant": { "org_id": "org-a", "workspace_id": "ws-a" } },
"discord": { "tenant": { "org_id": "", "workspace_id": "ws" } },
"telegram": {}
}
});
assert_eq!(
channel_bound_tenant(&cfg, ChannelKind::Slack),
Some(("org-a".to_string(), "ws-a".to_string()))
);
assert_eq!(channel_bound_tenant(&cfg, ChannelKind::Discord), None);
assert_eq!(channel_bound_tenant(&cfg, ChannelKind::Telegram), None);
}
}