Files
goclaw/cmd/gateway_subagent_announce_queue.go
T
viettranx 6a9b747b65 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.
2026-04-16 14:17:48 +07:00

233 lines
7.6 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
orch "github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// makeDelegateAnnounceCallback returns the batch callback used by tools.NewAnnounceQueue.
// Extracted from runGateway to keep the main function concise.
func makeDelegateAnnounceCallback(
subagentMgr *tools.SubagentManager,
msgBus *bus.MessageBus,
) func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
return func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
roster := subagentMgr.RosterForParent(meta.ParentAgent)
content := tools.FormatBatchedAnnounce(items, roster)
senderID := fmt.Sprintf("subagent:batch-%d", len(items))
label := items[0].Label
if len(items) > 1 {
label = fmt.Sprintf("%d tasks", len(items))
}
batchMeta := map[string]string{
tools.MetaOriginChannel: meta.OriginChannel,
tools.MetaOriginPeerKind: meta.OriginPeerKind,
tools.MetaParentAgent: meta.ParentAgent,
tools.MetaSubagentLabel: label,
tools.MetaOriginTraceID: meta.OriginTraceID,
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
}
if meta.OriginLocalKey != "" {
batchMeta[tools.MetaOriginLocalKey] = meta.OriginLocalKey
}
if meta.OriginSessionKey != "" {
batchMeta[tools.MetaOriginSessionKey] = meta.OriginSessionKey
}
if meta.OriginSenderID != "" {
batchMeta[tools.MetaOriginSenderID] = meta.OriginSenderID
}
if meta.OriginRole != "" {
batchMeta[tools.MetaOriginRole] = meta.OriginRole
}
if meta.OriginUserID != "" {
batchMeta[tools.MetaOriginUserID] = meta.OriginUserID
}
// Collect media from all items in the batch.
var batchMedia []bus.MediaFile
for _, item := range items {
batchMedia = append(batchMedia, item.Media...)
}
// Notify clients that leader is processing team results
// (bridges UI gap between last task.completed and announce run.started).
bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{
"agentId": meta.ParentAgent,
"tasks": len(items),
})
msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: senderID,
ChatID: meta.OriginChatID,
Content: content,
UserID: meta.OriginUserID,
TenantID: meta.OriginTenantID,
Metadata: batchMeta,
Media: batchMedia,
})
}
}
// subagentAnnounceEntry holds one subagent completion result waiting to be announced.
type subagentAnnounceEntry struct {
Label string
Status string // "completed", "failed", "cancelled"
Content string
Media []bus.MediaFile
InputTokens int64
OutputTokens int64
Runtime time.Duration
Iterations int
}
// subagentAnnounceRouting holds shared routing info captured by the first enqueue.
type subagentAnnounceRouting struct {
QueueKey string // tenant-scoped key for sync.Map (tenantID:sessionKey)
SessionKey string // original session key (no tenant prefix) for RunRequest
TenantID uuid.UUID // preserved for tenant-scoped scheduling
OrigChannel string
OrigChannelType string
OrigChatID string
OrigPeerKind string
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
OutMeta map[string]string
}
// subagentAnnounceQueue uses BatchQueue for producer-consumer synchronization.
var subagentAnnounceQueue orch.BatchQueue[subagentAnnounceEntry]
// enqueueSubagentAnnounce adds a result to the queue. Returns isProcessor.
func enqueueSubagentAnnounce(key string, entry subagentAnnounceEntry) bool {
return subagentAnnounceQueue.Enqueue(key, entry)
}
// processSubagentAnnounceLoop drains entries, builds merged announce, schedules to parent.
func processSubagentAnnounceLoop(
ctx context.Context,
r subagentAnnounceRouting,
roster tools.SubagentRoster,
subagentMgr *tools.SubagentManager,
sched *scheduler.Scheduler,
msgBus *bus.MessageBus,
cfg *config.Config,
) {
// Ensure tenant scope is always set for the scheduler.
if r.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, r.TenantID)
}
for {
select {
case <-ctx.Done():
subagentAnnounceQueue.TryFinish(r.QueueKey)
return
default:
}
entries := subagentAnnounceQueue.Drain(r.QueueKey)
if len(entries) == 0 {
if subagentAnnounceQueue.TryFinish(r.QueueKey) {
return
}
// Brief sleep to avoid tight spin when entries arrive between drain and tryFinish.
time.Sleep(50 * time.Millisecond)
continue
}
// Refresh roster each iteration for up-to-date task statuses.
roster = subagentMgr.RosterForParent(r.ParentAgent)
content := buildMergedSubagentAnnounce(entries, roster)
// Collect media from all entries.
var fwdMedia []bus.MediaFile
for _, e := range entries {
fwdMedia = append(fwdMedia, e.Media...)
}
contentSuffix := ""
if r.OrigChannel == "ws" && len(fwdMedia) > 0 {
contentSuffix = mediaToMarkdownFromPaths(fwdMedia, cfg)
fwdMedia = nil
}
req := agent.RunRequest{
SessionKey: r.SessionKey,
Message: content,
ForwardMedia: fwdMedia,
ContentSuffix: contentSuffix,
Channel: r.OrigChannel,
ChannelType: r.OrigChannelType,
ChatID: r.OrigChatID,
PeerKind: r.OrigPeerKind,
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,
Stream: false,
ParentTraceID: r.ParentTraceID,
ParentRootSpanID: r.ParentRootSpanID,
}
outCh := sched.Schedule(ctx, scheduler.LaneSubagent, req)
outcome := <-outCh
if outcome.Err != nil {
if !errors.Is(outcome.Err, context.Canceled) {
slog.Error("subagent announce: lead run failed", "error", outcome.Err, "batch_size", len(entries))
errContent := formatAgentError(outcome.Err)
if isExternalChannel(r.OrigChannelType) {
slog.Info("subagent announce: suppressed error for external channel",
"channel", r.OrigChannel, "type", r.OrigChannelType)
errContent = ""
}
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: r.OrigChannel,
ChatID: r.OrigChatID,
Content: errContent,
Metadata: r.OutMeta,
})
}
} else {
isSilent := outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content)
if !(isSilent && len(outcome.Result.Media) == 0) {
out := outcome.Result.Content
if isSilent {
out = ""
}
outMsg := bus.OutboundMessage{
Channel: r.OrigChannel,
ChatID: r.OrigChatID,
Content: out,
Metadata: r.OutMeta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}
}
slog.Info("subagent announce: batch processed",
"batch_size", len(entries), "session", r.SessionKey)
}
}