fix(im): inject synthetic identity so IM channels can use shared KBs

IM callback routes bypass the auth middleware because IM platforms verify
their own signatures, so the downstream QA pipeline never received a
UserID/TenantRole. The Organization-shared knowledge base resolution gates
on a non-empty UserID, so IM-channel questions silently skipped shared KBs
while the Web path (which sets UserID via JWT auth) worked.

Inject a synthetic "system-<tenantID>" identity (mirroring the API-key path,
recognised by types.IsSyntheticUserID) plus a Viewer role when building the
IM session context. Viewer is the least privilege sufficient to retrieve
shared KBs.

Closes #1600
This commit is contained in:
wizardchen
2026-06-04 16:38:54 +08:00
committed by lyingbug
parent d05de9463f
commit 12ea490d89
2 changed files with 55 additions and 2 deletions
+38
View File
@@ -0,0 +1,38 @@
package im
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestWithIMIdentity(t *testing.T) {
const tenantID uint64 = 42
ctx := withIMIdentity(context.Background(), tenantID)
gotTenant, ok := types.TenantIDFromContext(ctx)
if !ok || gotTenant != tenantID {
t.Fatalf("TenantID = %d (ok=%v), want %d", gotTenant, ok, tenantID)
}
userID, ok := types.UserIDFromContext(ctx)
if !ok || userID == "" {
t.Fatalf("UserID = %q (ok=%v), want non-empty synthetic user", userID, ok)
}
if want := "system-42"; userID != want {
t.Fatalf("UserID = %q, want %q", userID, want)
}
// The synthetic shape must be recognised so RBAC code does not record it
// as a resource creator.
if !types.IsSyntheticUserID(userID) {
t.Fatalf("IsSyntheticUserID(%q) = false, want true", userID)
}
// Non-empty UserID is the gate the shared-KB resolution relies on; without
// it Organization-shared KBs are silently skipped on the IM path.
if role := types.TenantRoleFromContext(ctx); role != types.TenantRoleViewer {
t.Fatalf("TenantRole = %v, want %v", role, types.TenantRoleViewer)
}
}
+17 -2
View File
@@ -450,6 +450,21 @@ func formatQuotedContext(quote *QuotedMessage) string {
return label + "\n<quoted_message>\n" + content + "\n</quoted_message>"
}
// withIMIdentity injects a synthetic caller identity into the context for IM
// callbacks. IM platforms verify their own signatures and bypass the auth
// middleware, so the downstream QA pipeline would otherwise see an empty
// UserID/TenantRole. Mirroring the API-key path's "system-<tenantID>" synthetic
// user (recognised by types.IsSyntheticUserID) lets Organization-shared
// knowledge bases be merged and resolved correctly, since the shared-KB code
// gates on a non-empty UserID. Viewer is the least privilege sufficient to
// retrieve shared KBs.
func withIMIdentity(ctx context.Context, tenantID uint64) context.Context {
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
ctx = context.WithValue(ctx, types.UserIDContextKey, fmt.Sprintf("system-%d", tenantID))
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
return ctx
}
func buildIMQARequest(
session *types.Session,
query string,
@@ -1126,8 +1141,8 @@ func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, chann
if err != nil {
return fmt.Errorf("get tenant: %w", err)
}
sessionCtx := context.WithValue(ctx, types.TenantIDContextKey, tenantID)
sessionCtx = context.WithValue(sessionCtx, types.TenantInfoContextKey, tenant)
sessionCtx := context.WithValue(ctx, types.TenantInfoContextKey, tenant)
sessionCtx = withIMIdentity(sessionCtx, tenantID)
// 2. Resolve or create a WeKnora session
channelSession, err := s.resolveSession(sessionCtx, msg, tenantID, agentID, channelID, channel.SessionMode)