feat: propagate RBAC role through dispatch + bypass file-writer grant for admins

Dashboard users (and other tenant-authenticated admin/operator/owner
actors) dispatching team tasks to agents in Telegram/Discord groups
previously hit the empty-sender DENY rule when the assigned agent
tried to write files — because dashboard users have tenant identities
(e.g. "viettx") that don't match the numeric file-writer grants stored
per channel.

Two-part fix:

1. CheckFileWriterPermission / CheckCronPermission add an RBAC bypass
   at the store layer: if ctx carries RoleFromContext in {admin,
   operator, owner}, skip the per-user grant check. Tenant RBAC already
   pre-authenticated these callers at the gateway edge; requiring a
   redundant per-channel grant blocks legitimate admin work.

2. Role propagation through the full dispatch chain:
     ctx (RoleFromContext)
       → RunRequest.Role
       → SubagentTask.OriginRole / AnnounceMetadata.OriginRole
         / DelegateRequest.Role
       → InboundMessage.Metadata[MetaOriginRole]
       → subagentAnnounceRouting.Role / teammateRole
       → RunRequest.Role (re-ingress)
       → store.WithRole(ctx, ...) in loop_context
   Covers subagent announce, delegate announce, teammate dispatch
   (WS dashboard, tool), session_send, and processNormalMessage's
   synthetic-sender override path. team_tasks_create persists
   origin_role for deferred dispatches.

Viewer and empty roles fall through to the existing sender-based path
(no bypass).

Regression tests:
- A.10 AdminRoleBypass: admin/operator/owner in ctx allowed even with
  empty sender (normally DENY).
- A.11 ViewerRoleDoesNotBypass: viewer and empty roles still hit DENY.

Builds + integration + unit tests green.
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent 51cf103714
commit 6a9b747b65
15 files changed
+87 -6

No files matched your search

+12 -6
View File
@@ -109,10 +109,12 @@ func handleSubagentAnnounce(
Iterations: iterations,
}
// Preserve real acting sender from original turn so permission checks
// (e.g. write_file in group chat) attribute to the user, not the
// synthetic "subagent:<id>" sender of the announce message itself (#915).
// Preserve real acting sender + RBAC role from original turn so permission
// checks (e.g. write_file in group chat) attribute to the user and can
// bypass per-user grants for authenticated admins, not the synthetic
// "subagent:<id>" sender of the announce message itself (#915).
originSenderID := msg.Metadata[tools.MetaOriginSenderID]
originRole := msg.Metadata[tools.MetaOriginRole]
queueKey := fmt.Sprintf("%s:%s", msg.TenantID, sessionKey)
routing := subagentAnnounceRouting{
@@ -126,6 +128,7 @@ func handleSubagentAnnounce(
OrigLocalKey: origLocalKey,
UserID: announceUserID,
SenderID: originSenderID,
Role: originRole,
ParentAgent: parentAgent,
ParentTraceID: parentTraceID,
ParentRootSpanID: parentRootSpanID,
@@ -211,10 +214,12 @@ func handleTeammateMessage(
announceUserID = fmt.Sprintf("group:%s:%s", origChannel, origChatID)
}
// Preserve real acting sender through teammate dispatch so permission
// checks during the teammate's turn (e.g. write_file in group chat)
// attribute to the original user (#915).
// Preserve real acting sender + RBAC role through teammate dispatch so
// permission checks during the teammate's turn (e.g. write_file in group
// chat) attribute to the original user and can bypass per-user grants
// for authenticated admins (#915).
teammateSenderID := msg.Metadata[tools.MetaOriginSenderID]
teammateRole := msg.Metadata[tools.MetaOriginRole]
outMeta := buildAnnounceOutMeta(origLocalKey)
@@ -245,6 +250,7 @@ func handleTeammateMessage(
LocalKey: origLocalKey,
UserID: announceUserID,
SenderID: teammateSenderID, // real user who triggered the teammate dispatch (#915)
Role: teammateRole, // RBAC role for admin bypass during teammate turn (#915)
RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata[tools.MetaFromAgent], msg.Metadata[tools.MetaToAgent]),
Stream: false,
TeamTaskID: msg.Metadata[tools.MetaTeamTaskID],
+6
View File
@@ -363,6 +363,11 @@ func processNormalMessage(
effectiveSenderID = realSender
}
}
// Role propagation: carry the RBAC role of the originating actor so
// permission checks during the re-ingress turn can bypass per-user
// grants for authenticated admins (#915). Only present when the
// upstream dispatch set MetaOriginRole.
effectiveRole := msg.Metadata[tools.MetaOriginRole]
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
@@ -378,6 +383,7 @@ func processNormalMessage(
LocalKey: msg.Metadata["local_key"],
UserID: userID,
SenderID: effectiveSenderID,
Role: effectiveRole,
SenderName: resolveSenderName(msg),
RunID: runID,
Stream: enableStream,
+5
View File
@@ -50,6 +50,9 @@ func makeDelegateAnnounceCallback(
if meta.OriginSenderID != "" {
batchMeta[tools.MetaOriginSenderID] = meta.OriginSenderID
}
if meta.OriginRole != "" {
batchMeta[tools.MetaOriginRole] = meta.OriginRole
}
if meta.OriginUserID != "" {
batchMeta[tools.MetaOriginUserID] = meta.OriginUserID
}
@@ -102,6 +105,7 @@ type subagentAnnounceRouting struct {
OrigLocalKey string
UserID string
SenderID string // real acting sender (preserves permission attribution through re-ingress, #915)
Role string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
ParentAgent string
ParentTraceID uuid.UUID
ParentRootSpanID uuid.UUID
@@ -176,6 +180,7 @@ func processSubagentAnnounceLoop(
LocalKey: r.OrigLocalKey,
UserID: r.UserID,
SenderID: r.SenderID, // preserves real acting sender for permission checks (#915)
Role: r.Role, // preserves RBAC role for admin bypass in group writes (#915)
RunID: fmt.Sprintf("subagent-announce-%s-%d", r.ParentAgent, len(entries)),
RunKind: "announce",
HideInput: true,