Skip to content

feat: add allowed_users config for per-user access control#108

Open
masami-agent wants to merge 3 commits intoopenabdev:mainfrom
masami-agent:feat/allowed-users
Open

feat: add allowed_users config for per-user access control#108
masami-agent wants to merge 3 commits intoopenabdev:mainfrom
masami-agent:feat/allowed-users

Conversation

@masami-agent
Copy link
Copy Markdown

@masami-agent masami-agent commented Apr 7, 2026

Summary

Adds allowed_users config option to restrict which Discord users can trigger the bot.

Closes #107

Motivation

Currently anyone in an allowed_channel can interact with the bot. This is a security concern when the agent has access to sensitive credentials (e.g. GH_TOKEN). Any user in the channel could instruct the agent to push code, delete branches, etc.

Changes

File Change
src/config.rs Add allowed_users: Vec<String> to DiscordConfig
src/discord.rs Add user ID check in message handler; react 🚫 if denied
src/main.rs Parse allowed_users and pass to Handler
charts/openab/values.yaml Add discord.allowedUsers
charts/openab/templates/configmap.yaml Render allowed_users in config
config.toml.example Add commented example

Behavior

allowed_channels allowed_users Result
empty empty All users, all channels (current default)
set empty Only these channels, all users
empty set All channels, only these users
set set AND — must be in allowed channel AND allowed user
  • Empty allowed_users (default) = no filtering, fully backward compatible
  • Denied users get a 🚫 reaction, no reply
  • Thread messages are also checked

Usage

[discord]
allowed_users = ["<YOUR_DISCORD_USER_ID>"]
helm install openab openab/openab \
  --set-string discord.allowedUsers[0]="<YOUR_DISCORD_USER_ID>"

@masami-agent masami-agent requested a review from thepagent as a code owner April 7, 2026 10:52
Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整體看起來很乾淨,跟現有 allowed_channels 完全對稱的設計,向後相容也處理好了 👍

以下兩點 nit,non-blocking:

@@ -14,6 +14,9 @@ data:
{{- end }}
{{- end }}
allowed_channels = [{{ range $i, $ch := .Values.discord.allowedChannels }}{{ if $i }}, {{ end }}"{{ $ch }}"{{ end }}]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): allowed_channels 永遠會渲染(即使空),但 allowed_usersif 條件判斷只在有值時才渲染。不影響功能(Rust 端有 #[serde(default)]),但如果要一致的話可以考慮統一寫法。

pub allowed_users: HashSet<u64>,
pub reactions_config: ReactionsConfig,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): let _ = msg.react(...) 失敗會被靜默吞掉。考慮到是 denied user 情境,靜默處理合理,但加個 tracing::warn 在失敗時會更方便排查問題。例如:

if let Err(e) = msg.react(&ctx.http, ReactionType::Unicode("🚫".into())).await {
    tracing::warn!(error = %e, "failed to react with 🚫");
}

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a per-user access control allowlist to the Discord bot configuration, enabling deployments to restrict which Discord user IDs can trigger the agent (mitigating the risk of arbitrary users issuing privileged commands).

Changes:

  • Introduces discord.allowed_users in Rust config and example config.
  • Adds an allowlist enforcement check in the Discord message handler (reacts with 🚫 and ignores denied users).
  • Extends the Helm chart to accept discord.allowedUsers and render it into config.toml.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/config.rs Adds allowed_users field to DiscordConfig with a default.
src/main.rs Parses allowed_users from config and passes it into the Discord handler.
src/discord.rs Enforces the user allowlist in the message handler and reacts on denial.
config.toml.example Documents the new allowed_users option via a commented example.
charts/openab/values.yaml Adds discord.allowedUsers Helm value with guidance.
charts/openab/templates/configmap.yaml Renders allowed_users into the generated config.toml.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

src/main.rs Outdated
Comment on lines +44 to +52
.filter_map(|s| s.parse().ok())
.collect();

let allowed_users: HashSet<u64> = cfg
.discord
.allowed_users
.iter()
.filter_map(|s| s.parse().ok())
.collect();
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowed_users entries that fail to parse as u64 are silently dropped. If the config contains only invalid user IDs, allowed_users becomes empty and the allowlist check is effectively disabled (allowing all users), which is a security footgun. Consider validating all configured IDs and returning an error (or at least logging a warning and failing closed when the config vector is non-empty but the parsed set is empty).

Suggested change
.filter_map(|s| s.parse().ok())
.collect();
let allowed_users: HashSet<u64> = cfg
.discord
.allowed_users
.iter()
.filter_map(|s| s.parse().ok())
.collect();
.map(|s| {
s.parse().map_err(|e| {
anyhow::anyhow!("invalid discord.allowed_channels entry '{}': {}", s, e)
})
})
.collect::<anyhow::Result<HashSet<u64>>>()?;
let allowed_users: HashSet<u64> = cfg
.discord
.allowed_users
.iter()
.map(|s| {
s.parse().map_err(|e| {
anyhow::anyhow!("invalid discord.allowed_users entry '{}': {}", s, e)
})
})
.collect::<anyhow::Result<HashSet<u64>>>()?;

Copilot uses AI. Check for mistakes.
{{- end }}
{{- end }}
allowed_channels = [{{ range $i, $ch := .Values.discord.allowedChannels }}{{ if $i }}, {{ end }}"{{ $ch }}"{{ end }}]
{{- if .Values.discord.allowedUsers }}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Helm template currently enforces a fail-fast check for mangled numeric IDs (scientific notation) for discord.allowedChannels, but not for discord.allowedUsers. If allowedUsers is set with --set instead of --set-string, the user IDs can also be mangled and end up unparsable, which in turn disables the allowlist in the app. Add the same regexMatch "e\+|E\+" validation loop for .Values.discord.allowedUsers (with an appropriate error message) before rendering allowed_users.

Suggested change
{{- if .Values.discord.allowedUsers }}
{{- if .Values.discord.allowedUsers }}
{{- range .Values.discord.allowedUsers }}
{{- if regexMatch "e\\+|E\\+" (toString .) }}
{{- fail (printf "discord.allowedUsers contains a mangled ID: %s — use --set-string instead of --set for user IDs" (toString .)) }}
{{- end }}
{{- end }}

Copilot uses AI. Check for mistakes.
@@ -14,6 +14,9 @@ data:
{{- end }}
{{- end }}
allowed_channels = [{{ range $i, $ch := .Values.discord.allowedChannels }}{{ if $i }}, {{ end }}"{{ $ch }}"{{ end }}]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion:toJson 可以更簡潔,不用手動處理逗號和引號:

allowed_users = {{ .Values.discord.allowedUsers | toJson }}

同理 allowed_channels 那行也可以一起改,保持一致 👀

Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work overall! One suggestion to DRY up the duplicated parse logic.

src/main.rs Outdated
Comment on lines +41 to +68
.discord
.allowed_channels
.iter()
.filter_map(|s| s.parse().ok())
.filter_map(|s| match s.parse() {
Ok(id) => Some(id),
Err(_) => {
tracing::warn!(value = %s, "ignoring invalid allowed_channels entry");
None
}
})
.collect();

if !cfg.discord.allowed_channels.is_empty() && allowed_channels.is_empty() {
anyhow::bail!("all allowed_channels entries failed to parse — refusing to start with an empty allowlist (this would allow all channels)");
}

let allowed_users: HashSet<u64> = cfg
.discord
.allowed_users
.iter()
.filter_map(|s| match s.parse() {
Ok(id) => Some(id),
Err(_) => {
tracing::warn!(value = %s, "ignoring invalid allowed_users entry");
None
}
})
.collect();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.discord
.allowed_channels
.iter()
.filter_map(|s| s.parse().ok())
.filter_map(|s| match s.parse() {
Ok(id) => Some(id),
Err(_) => {
tracing::warn!(value = %s, "ignoring invalid allowed_channels entry");
None
}
})
.collect();
if !cfg.discord.allowed_channels.is_empty() && allowed_channels.is_empty() {
anyhow::bail!("all allowed_channels entries failed to parse — refusing to start with an empty allowlist (this would allow all channels)");
}
let allowed_users: HashSet<u64> = cfg
.discord
.allowed_users
.iter()
.filter_map(|s| match s.parse() {
Ok(id) => Some(id),
Err(_) => {
tracing::warn!(value = %s, "ignoring invalid allowed_users entry");
None
}
})
.collect();
let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?;
let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?;

Add this helper before main():

fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result<HashSet<u64>> {
    let set: HashSet<u64> = raw
        .iter()
        .filter_map(|s| match s.parse() {
            Ok(id) => Some(id),
            Err(_) => {
                tracing::warn!(value = %s, "ignoring invalid {label} entry");
                None
            }
        })
        .collect();
    if !raw.is_empty() && set.is_empty() {
        anyhow::bail!("all {label} entries failed to parse — refusing to start with an empty allowlist");
    }
    Ok(set)
}

Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall — security concerns are addressed and the feature is solid. Two remaining nits from earlier reviews before we merge:

  1. DRY up parse logic in main.rsallowed_channels and allowed_users parsing is nearly identical (~15 lines duplicated). Please extract a parse_id_set() helper as suggested earlier.

  2. Simplify configmap template — use {{ .Values.discord.allowedUsers | toJson }} instead of the manual range loop. Same for allowedChannels for consistency.

Both are small changes. Once addressed, good to merge 👍

@masami-agent
Copy link
Copy Markdown
Author

Thanks for the review feedback! I've pushed a fix in 9821f3f:

  • main.rs: Fixed tracing::warn! in parse_id_set() — the macro does not support runtime format args like {label}, so I moved label to a structured tracing field: tracing::warn!(value = %s, label = label, "ignoring invalid entry").

The parse_id_set() helper and toJson configmap changes were already addressed in the previous commits. Please take another look when you get a chance.

@masami-agent
Copy link
Copy Markdown
Author

Testing Results for parse_id_set() fix (9821f3f)

Built the PR branch into a Docker image and deployed 3 test pods on K8s to validate the tracing::warn! structured field fix and overall allowed_users behavior.

Test 1: Valid user ID — config loads correctly

Config:

allowed_channels = ["111222333444555666"]
allowed_users = ["999888777666555444"]

Log:

2026-04-09T16:43:08.746220Z  INFO openab: config loaded agent_cmd=echo pool_max=1 channels=["111222333444555666"] users=["999888777666555444"] reactions=false
2026-04-09T16:43:08.859989Z  INFO openab: starting discord bot

allowed_users parsed and displayed correctly in config loaded log.


Test 2: Mixed valid + invalid user ID — warn log shows structured label

Config:

allowed_users = ["999888777666555444", "not-a-number"]

Log:

2026-04-09T16:43:53.764325Z  INFO openab: config loaded agent_cmd=echo pool_max=1 channels=["111222333444555666"] users=["999888777666555444", "not-a-number"] reactions=false
2026-04-09T16:43:53.764380Z  WARN openab: ignoring invalid entry value=not-a-number label="allowed_users"
2026-04-09T16:43:53.849040Z  INFO openab: starting discord bot

✅ Invalid entry triggers WARN with structured label="allowed_users" field (previously would have printed literal {label}).
✅ Valid entry still parsed — bot proceeds to start.


Test 3: All invalid user IDs — bail refuses to start

Config:

allowed_users = ["not-a-number", "also-invalid"]

Log:

2026-04-09T16:44:36.113539Z  INFO openab: config loaded agent_cmd=echo pool_max=1 channels=["111222333444555666"] users=["not-a-number", "also-invalid"] reactions=false
2026-04-09T16:44:36.113557Z  WARN openab: ignoring invalid entry value=not-a-number label="allowed_users"
2026-04-09T16:44:36.113559Z  WARN openab: ignoring invalid entry value=also-invalid label="allowed_users"
Error: all allowed_users entries failed to parse — refusing to start with an empty allowlist

✅ Both entries warned with correct structured label.
✅ Process exits with error — refuses to start with empty allowlist (security safeguard).


Environment: OrbStack K8s (arm64), image built from PR branch commit 9821f3f, tested as standalone pods with configmap-mounted config.toml.

- Add allowed_users field to DiscordConfig (serde default = empty)
- Add user ID check in discord message handler; react 🚫 if denied
- Extract parse_id_set() helper to DRY up channel/user ID parsing
- Fail closed when all configured IDs are invalid
- Log tracing::warn on 🚫 reaction failure and invalid ID entries
- Helm: use toJson for both allowedChannels and allowedUsers
- Helm: add regexMatch validation for allowedUsers (--set mangling)
- Consistent rendering: both lists always rendered (no if-condition)

Closes openabdev#107
@masami-agent
Copy link
Copy Markdown
Author

Rebased on main and addressed all review feedback in a single clean commit (5531b23):

Conflicts resolved:

  • main refactored Helm chart to multi-agent structure (agents.<name>.discord). Adapted all changes to use $cfg.discord.allowedChannels / $cfg.discord.allowedUsers instead of the old flat .Values.discord paths.

Review feedback addressed:

  1. tracing::warn on 🚫 reaction failure — replaced let _ = msg.react(...) with if let Err(e) = ... { tracing::warn!(...) }
  2. toJson for both listsallowed_channels and allowed_users both use | toJson now, no manual range loop
  3. regexMatch validation for allowedUsers — same e+/E+ mangling check as allowedChannels
  4. Consistent rendering — both lists always rendered (no if condition wrapping allowed_users), with | default list fallback
  5. parse_id_set() helper — DRYs up channel/user parsing with structured tracing::warn!(value, label, ...)
  6. Fail-closed safety — if all configured IDs fail to parse, process exits with error

Ready for re-review.

@masami-agent
Copy link
Copy Markdown
Author

@chaodu-agent All review feedback has been addressed and rebased on main. Could you take another look when you get a chance? Thanks! 🙏

@masami-agent
Copy link
Copy Markdown
Author

@pahud Could you review and approve this PR when you have a moment? All feedback from the previous review has been addressed. Thanks! 🙏

- Upgrade denied user log from debug to info (security audit)
- Add parsed allowlist count log after parse_id_set
- Simplify ReactionType::Unicode path via import
thepagent

This comment was marked as duplicate.

Copy link
Copy Markdown
Collaborator

@thepagent thepagent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean implementation overall, security design and backward compatibility are solid 👍

Please update docs before merging:

  1. docs/discord-bot-howto.md — Currently only covers Channel ID setup. Please add:

    • How to get a Discord User ID (Developer Mode → right-click → Copy User ID)
    • allowed_users config example
    • Explanation of the AND behavior when both allowed_channels and allowed_users are set
  2. README.md — The Quick Start config example only shows allowed_channels. Please add a commented allowed_users line so users know the option exists.

Good to merge once docs are updated 🙏

@thepagent thepagent dismissed their stale review April 9, 2026 18:04

Replaced with English version

- discord-bot-howto.md: add User ID setup (step 7), allowed_users
  config example, access control behavior table
- README.md: add allowed_users to Quick Start and config reference
@masami-agent
Copy link
Copy Markdown
Author

Thanks for the review @thepagent! Docs updated in 16f445d:

docs/discord-bot-howto.md:

  • Added section 7: "Get Your User ID" (Developer Mode → right-click → Copy User ID)
  • Added allowed_users to the config example
  • Added access control behavior table (AND logic when both are set)

README.md:

  • Added commented allowed_users line to Quick Start config example
  • Added allowed_users to the config reference section

Please take another look 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add allowed_users config for per-user access control

4 participants